diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index df6a36cc41..7b3464dc5e 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -80,6 +80,11 @@ + + + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 71d8e77312..390bdd52bb 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -44,6 +44,17 @@ + + + + + + + + + + + @@ -80,6 +91,8 @@ + + @@ -175,6 +188,7 @@ + @@ -204,6 +218,7 @@ + diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 996a924ac0..538a2f45f1 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -2,6 +2,16 @@ - + + + + + + + + + + + diff --git a/dotnet/samples/DeclarativeWorkflow/DeclarativeWorkflow.csproj b/dotnet/samples/DeclarativeWorkflow/DeclarativeWorkflow.csproj new file mode 100644 index 0000000000..ee1a58754c --- /dev/null +++ b/dotnet/samples/DeclarativeWorkflow/DeclarativeWorkflow.csproj @@ -0,0 +1,33 @@ + + + + Exe + net9.0 + net9.0 + $(ProjectsDebugTargetFrameworks) + enable + disable + 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 + $(NoWarn);CA1812 + + + + true + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/DeclarativeWorkflow/Program.cs b/dotnet/samples/DeclarativeWorkflow/Program.cs new file mode 100644 index 0000000000..c480e96c23 --- /dev/null +++ b/dotnet/samples/DeclarativeWorkflow/Program.cs @@ -0,0 +1,304 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Azure.Identity; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Declarative; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; + +namespace Demo.DeclarativeWorkflow; + +/// +/// HOW TO: Create a workflow from a declartive (yaml based) definition. +/// +/// +/// Configuration +/// Define FOUNDRY_PROJECT_ENDPOINT as a user-secret or environment variable that +/// points to your Foundry project endpoint. +/// Usage +/// Provide the path to the workflow definition file as the first argument. +/// All other arguments are intepreted as a queue of inputs. +/// When no input is queued, interactive input is requested from the console. +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + Program program = new(args); + await program.ExecuteAsync(); + } + + private async Task ExecuteAsync() + { + // Read and parse the declarative workflow. + Notify($"WORKFLOW: Parsing {Path.GetFullPath(this.WorkflowFile)}"); + + Stopwatch timer = Stopwatch.StartNew(); + + // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. + DeclarativeWorkflowOptions options = + new(new FoundryAgentProvider(this.FoundryEndpoint, new AzureCliCredential())) + { + Configuration = this.Configuration + }; + Workflow workflow = DeclarativeWorkflowBuilder.Build(this.WorkflowFile, options); + + Notify($"\nWORKFLOW: Defined {timer.Elapsed}"); + + Notify("\nWORKFLOW: Starting..."); + + // Run the workflow, just like any other workflow + string input = this.GetWorkflowInput(); + StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); + await this.MonitorWorkflowRunAsync(run); + + Notify("\nWORKFLOW: Done!"); + } + + private const string DefaultWorkflow = "HelloWorld.yaml"; + private const string ConfigKeyFoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT"; + + private static readonly Dictionary s_nameCache = []; + private static readonly HashSet s_fileCache = []; + + private string WorkflowFile { get; } + private string? WorkflowInput { get; } + private string FoundryEndpoint { get; } + private PersistentAgentsClient FoundryClient { get; } + private IConfiguration Configuration { get; } + + private Program(string[] args) + { + this.WorkflowFile = ParseWorkflowFile(args); + this.WorkflowInput = ParseWorkflowInput(args); + + this.Configuration = InitializeConfig(); + + this.FoundryEndpoint = this.Configuration[ConfigKeyFoundryEndpoint] ?? throw new InvalidOperationException($"Undefined configuration setting: {ConfigKeyFoundryEndpoint}"); + this.FoundryClient = new PersistentAgentsClient(this.FoundryEndpoint, new AzureCliCredential()); + } + + private async Task MonitorWorkflowRunAsync(StreamingRun run) + { + string? messageId = null; + + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is ExecutorInvokeEvent executorInvoked) + { + Debug.WriteLine($"STEP ENTER #{executorInvoked.ExecutorId}"); + } + else if (evt is ExecutorCompleteEvent executorComplete) + { + Debug.WriteLine($"STEP EXIT #{executorComplete.ExecutorId}"); + } + else if (evt is ExecutorFailureEvent executorFailure) + { + Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}"); + } + else if (evt is ConversationUpdateEvent invokeEvent) + { + Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}"); + } + else if (evt is AgentRunUpdateEvent streamEvent) + { + if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal)) + { + messageId = streamEvent.Update.MessageId; + + if (messageId is not null) + { + string? agentId = streamEvent.Update.AuthorName; + if (agentId is not null) + { + if (!s_nameCache.TryGetValue(agentId, out string? realName)) + { + PersistentAgent agent = await this.FoundryClient.Administration.GetAgentAsync(agentId); + s_nameCache[agentId] = agent.Name; + realName = agent.Name; + } + agentId = realName; + } + agentId ??= nameof(ChatRole.Assistant); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write($"\n{agentId.ToUpperInvariant()}:"); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" [{messageId}]"); + } + } + + ChatResponseUpdate? chatUpdate = streamEvent.Update.RawRepresentation as ChatResponseUpdate; + switch (chatUpdate?.RawRepresentation) + { + case MessageContentUpdate messageUpdate: + string? fileId = messageUpdate.ImageFileId ?? messageUpdate.TextAnnotation?.OutputFileId; + if (fileId is not null && s_fileCache.Add(fileId)) + { + BinaryData content = await this.FoundryClient.Files.GetFileContentAsync(fileId); + await DownloadFileContentAsync(Path.GetFileName(messageUpdate.TextAnnotation?.TextToReplace ?? "response.png"), content); + } + break; + } + try + { + Console.ResetColor(); + Console.Write(streamEvent.Data); + } + finally + { + Console.ResetColor(); + } + } + else if (evt is AgentRunResponseEvent messageEvent) + { + try + { + Console.WriteLine(); + if (messageEvent.Response.AgentId is null) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("ACTIVITY:"); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(messageEvent.Response?.Text.Trim()); + } + else + { + if (messageEvent.Response.Usage is not null) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($"[Tokens Total: {messageEvent.Response.Usage.TotalTokenCount}, Input: {messageEvent.Response.Usage.InputTokenCount}, Output: {messageEvent.Response.Usage.OutputTokenCount}]"); + } + } + } + finally + { + Console.ResetColor(); + } + } + } + } + + private static string ParseWorkflowFile(string[] args) + { + string workflowFile = args.FirstOrDefault() ?? DefaultWorkflow; + + if (!File.Exists(workflowFile) && !Path.IsPathFullyQualified(workflowFile)) + { + string? repoFolder = GetRepoFolder(); + if (repoFolder is not null) + { + workflowFile = Path.Combine(repoFolder, "Workflows", workflowFile); + workflowFile = Path.ChangeExtension(workflowFile, ".yaml"); + } + } + + if (!File.Exists(workflowFile)) + { + throw new InvalidOperationException($"Unable to locate workflow: {Path.GetFullPath(workflowFile)}."); + } + + return workflowFile; + + static string? GetRepoFolder() + { + DirectoryInfo? current = new(Directory.GetCurrentDirectory()); + + while (current is not null) + { + if (Directory.Exists(Path.Combine(current.FullName, ".git"))) + { + return current.FullName; + } + + current = current.Parent; + } + + return null; + } + } + + private string GetWorkflowInput() + { + string? input = this.WorkflowInput; + + try + { + Console.ForegroundColor = ConsoleColor.DarkGreen; + + Console.Write("\nINPUT: "); + + Console.ForegroundColor = ConsoleColor.White; + + if (!string.IsNullOrWhiteSpace(input)) + { + Console.WriteLine(input); + return input; + } + while (string.IsNullOrWhiteSpace(input)) + { + input = Console.ReadLine(); + } + + return input.Trim(); + } + finally + { + Console.ResetColor(); + } + } + + private static string? ParseWorkflowInput(string[] args) + { + if (args.Length == 0) + { + return null; + } + + string[] workflowInput = [.. args.Skip(1)]; + + return workflowInput.FirstOrDefault(); + } + + // Load configuration from user-secrets + private static IConfigurationRoot InitializeConfig() => + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); + + private static void Notify(string message) + { + Console.ForegroundColor = ConsoleColor.Cyan; + try + { + Console.WriteLine(message); + } + finally + { + Console.ResetColor(); + } + } + + private static async ValueTask DownloadFileContentAsync(string filename, BinaryData content) + { + string filePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(filename)); + filePath = Path.ChangeExtension(filePath, ".png"); + + await File.WriteAllBytesAsync(filePath, content.ToArray()); + + Process.Start( + new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/C start {filePath}" + }); + } +} diff --git a/dotnet/samples/DeclarativeWorkflow/README.md b/dotnet/samples/DeclarativeWorkflow/README.md new file mode 100644 index 0000000000..5229bd50c2 --- /dev/null +++ b/dotnet/samples/DeclarativeWorkflow/README.md @@ -0,0 +1,88 @@ +# Summary + +This demo showcases the ability to parse a declarative Foundry Workflow file (YAML) to build a `Workflow<>` +be executed using the same pattern as any code-based workflow. + +## Configuration + +This demo requires configuration to access agents an [Azure Foundry Project](https://learn.microsoft.com/azure/ai-foundry). + +#### Settings + +We suggest using .NET [Secret Manager](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets) +to avoid the risk of leaking secrets into the repository, branches and pull requests. +You can also use environment variables if you prefer. + +To set your secrets as an environment variable (PowerShell): + +```pwsh +$env:FOUNDRY_PROJECT_ENDPOINT="https://..." +``` + +To set your secrets with .NET Secret Manager: + +1. From the root of the respository, navigate the console to the project folder: + + ``` + cd dotnet/demos/DeclarativeWorkflow + ``` + +2. Examine existing secret definitions: + + ``` + dotnet user-secrets list + ``` + +3. If needed, perform first time initialization: + + ``` + dotnet user-secrets init + ``` + +4. Define setting that identifies your Azure Foundry Project (endpoint): + + ``` + dotnet user-secrets set "FOUNDRY_PROJECT_ENDPOINT" "https://..." + ``` + +#### Authorization + +Use [_Azure CLI_](https://learn.microsoft.com/cli/azure/authenticate-azure-cli) to authorize access to your Azure Foundry Project: + + ``` + az login + az account get-access-token + ``` + +#### Agents + +The sample workflows rely on agents defined in your Azure Foundry Project. + +To create agents, run the [`Create.ps1`](../../../workflows/) script. +This will create the agents used in the sample workflows in your Azure Foundry Project and format a script you can copy and use to configure your environment. + +> Note: `Create.ps1` relies upon the `FOUNDRY_PROJECT_ENDPOINT` setting. + +## Execution + +Run the demo from the console by specifying a path to a declarative (YAML) workflow file. +The repository has example workflows available in the root [`/workflows`](../../../workflows) folder. + +1. From the root of the respository, navigate the console to the project folder: + + ```sh + cd dotnet/demos/DeclarativeWorkflow + + ``` + +2. Run the demo referencing a sample workflow by name: + + ```sh + dotnet run HelloWorld + ``` + +3. Run the demo with a path to any workflow file: + + ```sh + dotnet run c:/myworkflows/HelloWorld.yaml + ``` diff --git a/dotnet/samples/GettingStarted/AgentOrchestration/AgentOrchestration.csproj b/dotnet/samples/GettingStarted/AgentOrchestration/AgentOrchestration.csproj index 5caf5fd086..43a254a910 100644 --- a/dotnet/samples/GettingStarted/AgentOrchestration/AgentOrchestration.csproj +++ b/dotnet/samples/GettingStarted/AgentOrchestration/AgentOrchestration.csproj @@ -14,7 +14,7 @@ $(ProjectsTargetFrameworks) $(ProjectsDebugTargetFrameworks) - + @@ -47,14 +47,14 @@ - + - + @@ -62,4 +62,4 @@ - + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ConversationUpdateEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ConversationUpdateEvent.cs new file mode 100644 index 0000000000..7984ffcd11 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ConversationUpdateEvent.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Event that represents a message produced by a declarative workflow. +/// +public class ConversationUpdateEvent(string executorid, string conversationId) : ExecutorEvent(executorid, conversationId) +{ + /// + /// The conversation ID associated with the workflow. + /// + public string ConversationId { get; } = conversationId; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs new file mode 100644 index 0000000000..97a7d93328 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Yaml; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Builder for converting a Foundry workflow object-model YAML definition into a process. +/// +public static class DeclarativeWorkflowBuilder +{ + /// + /// Builds a process from the provided YAML definition of a CPS Topic ObjectModel. + /// + /// The type of the input message + /// The path to the workflow. + /// The execution context for the workflow. + /// An optional function to transform the input message into a . + /// + public static Workflow Build( + string workflowFile, + DeclarativeWorkflowOptions options, + Func? inputTransform = null) + where TInput : notnull + { + using StreamReader yamlReader = File.OpenText(workflowFile); + return Build(yamlReader, options, inputTransform); + } + /// + /// Builds a process from the provided YAML definition of a CPS Topic ObjectModel. + /// + /// The type of the input message + /// The reader that provides the workflow object model YAML. + /// The execution context for the workflow. + /// An optional function to transform the input message into a . + /// The that corresponds with the YAML object model. + public static Workflow Build( + TextReader yamlReader, + DeclarativeWorkflowOptions options, + Func? inputTransform = null) + where TInput : notnull + { + BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new DeclarativeModelException("Workflow undefined."); + + // ISSUE #486 - Use "Workflow" element for Foundry. + if (rootElement is not AdaptiveDialog workflowElement) + { + throw new DeclarativeModelException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(AdaptiveDialog)}."); + } + + string rootId = WorkflowActionVisitor.RootId(workflowElement.BeginDialog?.Id.Value ?? "workflow"); + + WorkflowScopes scopes = new(); + scopes.Initialize(WrapWithBot(workflowElement), options.Configuration); + DeclarativeWorkflowState state = new(options.CreateRecalcEngine(), scopes); + DeclarativeWorkflowExecutor rootExecutor = + new(rootId, + state, + message => inputTransform?.Invoke(message) ?? DefaultTransform(message)); + + WorkflowActionVisitor visitor = new(rootExecutor, state, options); + WorkflowElementWalker walker = new(rootElement, visitor); + + return walker.GetWorkflow(); + } + + private static ChatMessage DefaultTransform(object message) => + message switch + { + ChatMessage chatMessage => chatMessage, + string stringMessage => new ChatMessage(ChatRole.User, stringMessage), + _ => new(ChatRole.User, $"{message}") + }; + + // Wrap with bot to ensure schema is set. + private static AdaptiveDialog WrapWithBot(AdaptiveDialog dialog) + { + BotDefinition bot + = new BotDefinition.Builder + { + Components = + { + new DialogComponent.Builder + { + SchemaName = dialog.HasSchemaName ? dialog.SchemaName : "default-schema", + Dialog = new AdaptiveDialog.Builder(dialog), + } + } + }.Build(); + + return bot.Descendants().OfType().First(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs new file mode 100644 index 0000000000..1f61d27253 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Configuration options for workflow execution. +/// +public sealed class DeclarativeWorkflowOptions(WorkflowAgentProvider agentProvider) +{ + /// + /// Defines the agent provider. + /// + public WorkflowAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider, nameof(agentProvider)); + + /// + /// Defines the configuration settings for the workflow. + /// + public IConfiguration? Configuration { get; init; } + + /// + /// Optionally identifies a continued workflow conversation. + /// + public string? ConversationId { get; init; } + + /// + /// 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 used to create loggers for workflow components. + /// + public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeActionException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeActionException.cs new file mode 100644 index 0000000000..6849a57bf2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeActionException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents an exception that occurs during action execution. +/// +public sealed class DeclarativeActionException : DeclarativeWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public DeclarativeActionException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public DeclarativeActionException(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 DeclarativeActionException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeModelException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeModelException.cs new file mode 100644 index 0000000000..f6b6adfb11 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeModelException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents an exception that occurs when the declarative model is not supported. +/// +public class DeclarativeModelException : DeclarativeWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public DeclarativeModelException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public DeclarativeModelException(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 DeclarativeModelException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeWorkflowException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeWorkflowException.cs new file mode 100644 index 0000000000..1cad0fb2c8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeWorkflowException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents any exception that occurs during the execution of a process workflow. +/// +public class DeclarativeWorkflowException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public DeclarativeWorkflowException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public DeclarativeWorkflowException(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 DeclarativeWorkflowException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs new file mode 100644 index 0000000000..bb409944f2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class BotElementExtensions +{ + 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 DeclarativeModelException($"Undefined identifier for {nameof(ConditionItem)} that is member of {conditionItem.GetParentId() ?? "(root)"}."), + OnActivity activity => activity.Id.Value, + _ => throw new DeclarativeModelException($"Unknown identify for element type: {element.GetType().Name}"), + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs new file mode 100644 index 0000000000..89a65c86b2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class ChatMessageExtensions +{ + // ISSUE #485 - Align with message type updated OM is available. + public static RecordValue ToRecord(this ChatMessage message) => + RecordValue.NewRecordFromFields(message.GetMessageFields()); + + private static IEnumerable GetMessageFields(this ChatMessage message) + { + yield return new NamedValue(nameof(DialogAction.Id), message.MessageId.ToFormulaValue()); + yield return new NamedValue(nameof(ChatMessage.Role), FormulaValue.New(message.Role.Value)); + yield return new NamedValue(nameof(ChatMessage.AuthorName), message.AuthorName.ToFormulaValue()); + yield return new NamedValue(nameof(ChatMessage.Text), message.Text.ToFormulaValue()); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs new file mode 100644 index 0000000000..81666b6013 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class DataValueExtensions +{ + 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( + tableValue.Values.FirstOrDefault()?.ParseRecordType() ?? RecordType.Empty(), + tableValue.Values.Select(value => value.ToRecordValue())), + RecordDataValue recordValue => recordValue.ToRecordValue(), + OptionDataValue optionValue => FormulaValue.New(optionValue.Value.Value), + _ => FormulaValue.NewError(new Microsoft.PowerFx.ExpressionError { Message = $"Unknown literal type: {value.GetType().Name}" }), + }; + + public static FormulaType ToFormulaType(this DataValue? value) => value?.GetDataType().ToFormulaType() ?? FormulaType.Blank; + + public static FormulaType ToFormulaType(this DataType? type) => + type switch + { + null => FormulaType.Blank, + BooleanDataType => FormulaType.Boolean, + NumberDataType => FormulaType.Decimal, + FloatDataType => FormulaType.Number, + StringDataType => FormulaType.String, + DateTimeDataType => FormulaType.DateTime, + DateDataType => FormulaType.Date, + TimeDataType => FormulaType.Time, + ColorDataType => FormulaType.Color, + GuidDataType => FormulaType.Guid, + FileDataType => FormulaType.Blob, + RecordDataType => RecordType.Empty(), + TableDataType => TableType.Empty(), + OptionSetDataType => FormulaType.String, + AnyType => FormulaType.UntypedObject, + _ => FormulaType.Unknown, + }; + + public static FormulaValue NewBlank(this DataType? type) => FormulaValue.NewBlank(type?.ToFormulaType() ?? FormulaType.Blank); + + public static RecordValue ToRecordValue(this RecordDataValue recordDataValue) => + FormulaValue.NewRecordFromFields( + recordDataValue.Properties.Select( + property => new NamedValue(property.Key, property.Value.ToFormulaValue()))); + + public static RecordType ToRecordType(this RecordDataType record) + { + RecordType recordType = RecordType.Empty(); + foreach (KeyValuePair property in record.Properties) + { + recordType = recordType.Add(property.Key, property.Value.Type.ToFormulaType()); + } + return recordType; + } + + private static RecordType ParseRecordType(this RecordDataValue record) + { + RecordType recordType = RecordType.Empty(); + foreach (KeyValuePair property in record.Properties) + { + recordType = recordType.Add(property.Key, property.Value.ToFormulaType()); + } + return recordType; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs new file mode 100644 index 0000000000..19b2a11819 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.PowerFx; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class DeclarativeWorkflowOptionsExtensions +{ + private const int DefaultMaximumExpressionLength = 10000; + + public static RecalcEngine CreateRecalcEngine(this DeclarativeWorkflowOptions? context) => + RecalcEngineFactory.Create(context?.MaximumExpressionLength ?? DefaultMaximumExpressionLength, context?.MaximumCallDepth); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs new file mode 100644 index 0000000000..568f3ba104 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Dynamic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using BlankType = Microsoft.PowerFx.Types.BlankType; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class FormulaValueExtensions +{ + private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; + + public static FormulaValue NewBlank(this FormulaType? type) => FormulaValue.NewBlank(type ?? FormulaType.Blank); + + public static FormulaValue ToFormulaValue(this object? value) => + value switch + { + null => FormulaValue.NewBlank(), + FormulaValue formulaValue => formulaValue, + bool booleanValue => FormulaValue.New(booleanValue), + int decimalValue => FormulaValue.New(decimalValue), + long decimalValue => FormulaValue.New(decimalValue), + float decimalValue => FormulaValue.New(decimalValue), + decimal decimalValue => FormulaValue.New(decimalValue), + double numberValue => FormulaValue.New(numberValue), + string stringValue => FormulaValue.New(stringValue), + DateTime dateonlyValue when dateonlyValue.TimeOfDay == TimeSpan.Zero => FormulaValue.NewDateOnly(dateonlyValue), + DateTime datetimeValue => FormulaValue.New(datetimeValue), + TimeSpan timeValue => FormulaValue.New(timeValue), + object when value is IEnumerable tableValue => tableValue.ToTable(), + ExpandoObject expandoValue => expandoValue.ToRecord(), + _ => throw new DeclarativeModelException($"Unsupported variable type: {value.GetType().Name}"), + }; + + public static FormulaType GetFormulaType(this object? value) => + value switch + { + null => FormulaType.Blank, + bool => FormulaType.Boolean, + int => FormulaType.Decimal, + long => FormulaType.Decimal, + float => FormulaType.Decimal, + decimal => FormulaType.Decimal, + double => FormulaType.Number, + string => FormulaType.String, + DateTime => FormulaType.DateTime, + TimeSpan => FormulaType.Time, + object when value is IEnumerable tableValue => tableValue.ToTableType(), + ExpandoObject expandoValue => expandoValue.ToRecordType(), + _ => FormulaType.Unknown, + }; + + public static DataValue ToDataValue(this FormulaValue value) => + value switch + { + BooleanValue booleanValue => BooleanDataValue.Create(booleanValue.Value), + DecimalValue decimalValue => NumberDataValue.Create(decimalValue.Value), + NumberValue numberValue => FloatDataValue.Create(numberValue.Value), + DateValue dateValue => DateDataValue.Create(dateValue.GetConvertedValue(TimeZoneInfo.Utc)), + DateTimeValue datetimeValue => DateTimeDataValue.Create(datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)), + TimeValue timeValue => TimeDataValue.Create(timeValue.Value), + StringValue stringValue => StringDataValue.Create(stringValue.Value), + BlankValue blankValue => DataValue.Blank(), + VoidValue voidValue => DataValue.Blank(), + RecordValue recordValue => recordValue.ToRecord(), + TableValue tableValue => tableValue.ToTable(), + _ => throw new DeclarativeModelException($"Unsupported variable type: {value.GetType().Name}"), + }; + + public static DataType GetDataType(this FormulaValue value) => + value switch + { + null => DataType.Blank, + BooleanValue => DataType.Boolean, + DecimalValue => DataType.Number, + NumberValue => DataType.Float, + DateValue => DataType.Date, + DateTimeValue => DataType.DateTime, + TimeValue => DataType.Time, + StringValue => DataType.String, + BlankValue => DataType.Blank, + ColorValue => DataType.Color, + GuidValue => DataType.Guid, + BlobValue => DataType.File, + RecordValue recordValue => recordValue.Type.ToDataType(), + TableValue tableValue => tableValue.Type.ToDataType(), + UntypedObjectValue => DataType.Any, + _ => DataType.Unspecified, + }; + + public static DataType ToDataType(this FormulaType type) => + 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, + BlankType => DataType.Blank, + ColorType => DataType.Color, + GuidType => DataType.Guid, + BlobType => DataType.File, + RecordType recordType => recordType.ToDataType(), + TableType tableType => tableType.ToDataType(), + UntypedObjectType => DataType.Any, + _ => 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, + BlankValue blankValue => string.Empty, + VoidValue voidValue => string.Empty, + ColorValue colorValue => colorValue.Value.ToString(), + GuidValue guidValue => guidValue.Value.ToString("N"), + TableValue tableValue => tableValue.ToJson().ToJsonString(s_options), + RecordValue recordValue => recordValue.ToJson().ToJsonString(s_options), + ErrorValue errorValue => $"Error:{Environment.NewLine}{string.Join(Environment.NewLine, errorValue.Errors.Select(error => $"{error.MessageKey}: {error.Message}"))}", + _ => $"[{value.GetType().Name}]", + }; + + public static TableDataValue ToTable(this TableValue value) => + TableDataValue.TableFromRecords(value.Rows.Select(row => row.Value.ToRecord()).ToImmutableArray()); + + public static RecordDataValue ToRecord(this RecordValue value) => + RecordDataValue.RecordFromFields(value.OriginalFields.Select(field => field.GetKeyValuePair()).ToImmutableArray()); + + private static RecordDataType ToDataType(this RecordType record) + { + RecordDataType recordType = new(); + foreach (string fieldName in record.FieldNames) + { + recordType.Properties.Add(fieldName, PropertyInfo.Create(record.GetFieldType(fieldName).ToDataType())); + } + return recordType; + } + + private static TableDataType ToDataType(this TableType table) + { + TableDataType tableType = new(); + foreach (string fieldName in table.FieldNames) + { + tableType.Properties.Add(fieldName, PropertyInfo.Create(table.GetFieldType(fieldName).ToDataType())); + } + return tableType; + } + + private static RecordType ToRecordType(this ExpandoObject value) + { + RecordType recordType = RecordType.Empty(); + foreach (KeyValuePair property in value) + { + recordType.Add(property.Key, property.Value.GetFormulaType()); + } + return recordType; + } + + private static RecordValue ToRecord(this ExpandoObject value) => + FormulaValue.NewRecordFromFields( + value.Select( + property => new NamedValue(property.Key, property.Value.ToFormulaValue()))); + + private static TableType ToTableType(this IEnumerable value) + { + Type valueType = value.GetType(); + Type? elementType = valueType.GetElementType() ?? valueType.GetGenericArguments().FirstOrDefault(); + + if (elementType is not null) + { + if (elementType != typeof(ExpandoObject)) + { + throw new DeclarativeModelException($"Invalid table element: {elementType.Name}"); + } + + foreach (ExpandoObject element in value) + { + return element.ToRecordType().ToTable(); + } + } + + return TableType.Empty(); + } + + private static TableValue ToTable(this IEnumerable value) + { + Type valueType = value.GetType(); + Type? elementType = valueType.GetElementType() ?? valueType.GetGenericArguments().FirstOrDefault(); + + if (elementType is null) + { + return FormulaValue.NewTable(RecordType.EmptySealed()); + } + + if (elementType != typeof(ExpandoObject)) + { + throw new DeclarativeModelException($"Invalid table element: {elementType.Name}"); + } + + List records = [.. value.OfType().Select(element => element.ToRecord())]; + + return FormulaValue.NewTable(value.ToTableType().ToRecord(), records); + } + + private static KeyValuePair GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.ToDataValue()); + + private static JsonNode ToJson(this FormulaValue value) => + value switch + { + BooleanValue booleanValue => JsonValue.Create(booleanValue.Value), + DecimalValue decimalValue => JsonValue.Create(decimalValue.Value), + NumberValue numberValue => JsonValue.Create(numberValue.Value), + DateValue dateValue => JsonValue.Create(dateValue.GetConvertedValue(TimeZoneInfo.Utc)), + DateTimeValue datetimeValue => JsonValue.Create(datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)), + TimeValue timeValue => JsonValue.Create($"{timeValue.Value}"), + StringValue stringValue => JsonValue.Create(stringValue.Value), + GuidValue guidValue => JsonValue.Create(guidValue.Value), + RecordValue recordValue => recordValue.ToJson(), + TableValue tableValue => tableValue.ToJson(), + BlankValue blankValue => JsonValue.Create(string.Empty), + _ => $"[{value.GetType().Name}]", + }; + + private static JsonArray ToJson(this TableValue value) + { + return new([.. GetJsonElements()]); + + IEnumerable GetJsonElements() + { + foreach (DValue row in value.Rows) + { + RecordValue recordValue = row.Value; + yield return recordValue.ToJson(); + } + } + } + + private static JsonObject ToJson(this RecordValue value) + { + JsonObject jsonObject = []; + foreach (NamedValue field in value.OriginalFields) + { + jsonObject.Add(field.Name, field.Value.ToJson()); + } + return jsonObject; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/PropertyPathExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/PropertyPathExtensions.cs new file mode 100644 index 0000000000..2fbc5c28ef --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/PropertyPathExtensions.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class PropertyPathExtensions +{ + public static string Format(this PropertyPath path) => string.Join(".", path.Segments()); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs new file mode 100644 index 0000000000..977a0217db --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.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 => DateTimeValue.New(propertyElement.GetDateTime()), + DateDataType => DateValue.New(propertyElement.GetDateTime()), + TimeDataType => TimeValue.New(propertyElement.GetDateTimeOffset().TimeOfDay), + RecordDataType recordType => recordType.ParseRecord(propertyElement), + TableDataType tableType => ParseTable(tableType, propertyElement), + _ => throw new InvalidOperationException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'"), + }; + yield return new NamedValue(property.Key, parsedValue); + } + + static TableValue ParseTable(TableDataType tableType, JsonElement propertyElement) + { + RecordDataType recordType = tableType.ToRecord(); + return + FormulaValue.NewTable( + recordType.ToRecordType(), + propertyElement.EnumerateArray().Select(tableElement => ParseRecord(recordType, tableElement))); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs new file mode 100644 index 0000000000..0f3a078d2c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.RegularExpressions; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.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(); + } + + public static FormulaValue ToFormulaValue(this string? value) => + string.IsNullOrWhiteSpace(value) ? FormulaValue.NewBlank() : FormulaValue.New(value); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs new file mode 100644 index 0000000000..9a41554a18 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/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.Agents.Workflows.Declarative.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]); + } + + public 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 DeclarativeModelException($"Unsupported segment type: {segment.GetType().Name}"); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs new file mode 100644 index 0000000000..0d0138f261 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Reflection; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; + +internal sealed record class DeclarativeExecutorResult(string ExecutorId, object? Result = null); + +internal abstract class DeclarativeActionExecutor(TAction model, DeclarativeWorkflowState state) : + WorkflowActionExecutor(model, state) + where TAction : DialogAction +{ + public new TAction Model => (TAction)base.Model; +} + +internal abstract class WorkflowActionExecutor : + ReflectingExecutor, + IMessageHandler +{ + public const string RootActionId = "(root)"; + + private static readonly ImmutableHashSet s_mutableScopes = + new HashSet + { + VariableScopeNames.Topic, + VariableScopeNames.Global, + }.ToImmutableHashSet(); + + private string? _parentId; + + protected WorkflowActionExecutor(DialogAction model, DeclarativeWorkflowState state) + : base(model.Id.Value) + { + if (!model.HasRequiredProperties) + { + throw new DeclarativeModelException($"Missing required properties for element: {model.GetId()} ({model.GetType().Name})."); + } + + this.Model = model; + this.State = state; + } + + public DialogAction Model { get; } + + public string ParentId => this._parentId ??= this.Model.GetParentId() ?? RootActionId; + + internal ILogger Logger { get; set; } = NullLogger.Instance; + + protected DeclarativeWorkflowState State { get; } + + /// + public async ValueTask HandleAsync(DeclarativeExecutorResult message, IWorkflowContext context) + { + if (this.Model.Disabled) + { + Debug.WriteLine($"DISABLED {this.GetType().Name} [{this.Id}]"); + return; + } + + await this.State.RestoreAsync(context, default).ConfigureAwait(false); + + try + { + object? result = await this.ExecuteAsync(context, cancellationToken: default).ConfigureAwait(false); + + await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id, result)).ConfigureAwait(false); + } + catch (DeclarativeActionException exception) + { + Debug.WriteLine($"ERROR [{this.Id}] {exception.GetType().Name}\n{exception.Message}"); + throw; + } + catch (Exception exception) + { + Debug.WriteLine($"ERROR [{this.Id}] {exception.GetType().Name}\n{exception.Message}"); + throw new DeclarativeActionException($"Unhandled workflow failure - #{this.Id} ({this.Model.GetType().Name})", exception); + } + } + + protected abstract ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default); + + protected async ValueTask AssignAsync(PropertyPath targetPath, FormulaValue result, IWorkflowContext context) + { + if (!s_mutableScopes.Contains(Throw.IfNull(targetPath.VariableScopeName))) + { + throw new DeclarativeModelException($"Invalid scope: {targetPath.VariableScopeName}"); + } + + await this.State.SetAsync(targetPath, result, context).ConfigureAwait(false); + +#if DEBUG + string? resultValue = result.Format(); + string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " "; + Debug.WriteLine( + $""" + STATE: {this.GetType().Name} [{this.Id}] + NAME: {targetPath.Format()} + VALUE:{valuePosition}{result.Format()} ({result.GetType().Name}) + """); +#endif + } + + protected DeclarativeActionException Exception(string text, Exception? exception = null) + { + string message = $"Unexpected workflow failure during {this.Model.GetType().Name} [{this.Id}]: {text}"; + return exception is null ? new(message) : new(message, exception); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs new file mode 100644 index 0000000000..573e931f9c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Agents.Workflows.Reflection; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; + +/// +/// The root executor for a declarative workflow. +/// +internal sealed class DeclarativeWorkflowExecutor(string workflowId, DeclarativeWorkflowState state, Func inputTransform) : + ReflectingExecutor>(workflowId), + IMessageHandler + where TInput : notnull +{ + public async ValueTask HandleAsync(TInput message, IWorkflowContext context) + { + ChatMessage input = inputTransform.Invoke(message); + await state.SetLastMessageAsync(context, input).ConfigureAwait(false); + + await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id)).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs new file mode 100644 index 0000000000..1d8f511f55 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; + +/// +/// Provides dynamic model for constructing a declarative workflow. +/// +internal sealed class DeclarativeWorkflowModel +{ + public DeclarativeWorkflowModel(Executor rootStep) + { + this.DefineNode(rootStep); + } + + private Dictionary Nodes { get; } = []; + + private List Links { get; } = []; + + public int GetDepth(string? nodeId) + { + if (nodeId == null) + { + return 0; + } + + if (!this.Nodes.TryGetValue(nodeId, out ModelNode? sourceNode)) + { + throw new DeclarativeModelException($"Unresolved step: {nodeId}."); + } + + return sourceNode.Depth; + } + + public void AddNode(Executor executor, string parentId, Action? completionHandler = null) + { + if (!this.Nodes.TryGetValue(parentId, out ModelNode? parentNode)) + { + throw new DeclarativeModelException($"Unresolved parent for {executor.Id}: {parentId}."); + } + + ModelNode stepNode = this.DefineNode(executor, parentNode, executor.GetType(), completionHandler); + + parentNode.Children.Add(stepNode); + } + + public void AddLinkFromPeer(string parentId, string targetId, Func? condition = null) + { + if (!this.Nodes.TryGetValue(parentId, out ModelNode? parentNode)) + { + throw new DeclarativeModelException($"Unresolved step: {parentId}."); + } + + if (parentNode.Children.Count == 0) + { + throw new DeclarativeModelException($"Cannot add a link from a node with no children: {parentId}."); + } + + ModelNode sourceNode = parentNode.Children.Count == 1 ? parentNode : parentNode.Children[parentNode.Children.Count - 2]; + + this.Links.Add(new ModelLink(sourceNode, targetId, condition)); + } + + public void AddLink(string sourceId, string targetId, Func? condition = null) + { + if (!this.Nodes.TryGetValue(sourceId, out ModelNode? sourceNode)) + { + throw new DeclarativeModelException($"Unresolved step: {sourceId}."); + } + + this.Links.Add(new ModelLink(sourceNode, targetId, condition)); + } + + public void ConnectNodes(WorkflowBuilder workflowBuilder) + { + foreach (ModelNode node in this.Nodes.Values.ToImmutableArray()) + { + if (node.CompletionHandler is not null) + { + Debug.WriteLine($"> CLOSE: {node.Id} (x{node.Children.Count})"); + + node.CompletionHandler.Invoke(); + } + } + + foreach (ModelLink link in this.Links) + { + if (!this.Nodes.TryGetValue(link.TargetId, out ModelNode? targetNode)) + { + throw new DeclarativeModelException($"Unresolved target for {link.Source.Id}: {link.TargetId}."); + } + + Debug.WriteLine($"> CONNECT: {link.Source.Id} => {link.TargetId}{(link.Condition is null ? string.Empty : " (?)")}"); + + workflowBuilder.AddEdge(link.Source.Executor, targetNode.Executor, link.Condition); + } + } + + private ModelNode DefineNode(Executor executor, ModelNode? parentNode = null, Type? executorType = null, Action? completionHandler = null) + { + ModelNode stepNode = new(executor, parentNode, executorType, completionHandler); + + this.Nodes.Add(stepNode.Id, stepNode); + + return stepNode; + } + + internal TAction? LocateParent(string? itemId) where TAction : Executor + { + if (string.IsNullOrEmpty(itemId)) + { + return null; + } + + while (itemId != null) + { + if (!this.Nodes.TryGetValue(itemId, out ModelNode? itemNode)) + { + throw new DeclarativeModelException($"Unresolved child: {itemId}."); + } + + if (itemNode.ExecutorType == typeof(TAction)) + { + return (TAction)itemNode.Executor; + } + + itemId = itemNode.Parent?.Id; + } + + return null; + } + + private sealed class ModelNode(Executor executor, ModelNode? parent = null, Type? executorType = null, Action? completionHandler = null) + { + public string Id => executor.Id; + + public Executor Executor => executor; + + public Type? ExecutorType => executorType; + + public ModelNode? Parent { get; } = parent; + + public List Children { get; } = []; + + public int Depth => this.Parent?.Depth + 1 ?? 0; + + public Action? CompletionHandler => completionHandler; + } + + private sealed record class ModelLink(ModelNode Source, string TargetId, Func? Condition = null); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs new file mode 100644 index 0000000000..fbbc77c48d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; + +internal sealed class DeclarativeWorkflowState +{ + private static readonly ImmutableHashSet s_mutableScopes = + new HashSet + { + VariableScopeNames.Topic, + VariableScopeNames.Global, + VariableScopeNames.System, + }.ToImmutableHashSet(); + + private readonly RecalcEngine _engine; + private readonly WorkflowScopes _scopes; + private WorkflowExpressionEngine? _expressionEngine; + private int _isInitialized; + + public DeclarativeWorkflowState(RecalcEngine engine, WorkflowScopes? scopes = null) + { + this._scopes = scopes ?? new WorkflowScopes(); + this._engine = engine; + this._scopes.Bind(this._engine); + } + + public WorkflowExpressionEngine ExpressionEngine => this._expressionEngine ??= new WorkflowExpressionEngine(this._engine); + + public void Reset(PropertyPath variablePath) => + this.Reset(Throw.IfNull(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); + + public void Reset(string scopeName, string? varName = null) + { + if (string.IsNullOrWhiteSpace(varName)) + { + this._scopes.Clear(scopeName); + } + else + { + this._scopes.Reset(varName, scopeName); + } + + this._scopes.Bind(this._engine, scopeName); + } + + public FormulaValue Get(PropertyPath variablePath) => + this.Get(Throw.IfNull(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); + + public FormulaValue Get(string scope, string varName) => + this._scopes.Get(varName, scope); + + public ValueTask SetAsync(PropertyPath variablePath, FormulaValue value, IWorkflowContext context) => + this.SetAsync(Throw.IfNull(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName), value, context); + + public async ValueTask SetAsync(string scopeName, string varName, FormulaValue value, IWorkflowContext context) + { + if (!s_mutableScopes.Contains(scopeName)) + { + throw new DeclarativeModelException($"Invalid scope: {scopeName}"); + } + + this._scopes.Set(varName, value, scopeName); + this._scopes.Bind(this._engine, scopeName); + + await context.QueueStateUpdateAsync(varName, value.ToObject(), scopeName).ConfigureAwait(false); + } + + public string? Format(IEnumerable template) => this._engine.Format(template); + + public string? Format(TemplateLine? line) => this._engine.Format(line); + + public async ValueTask RestoreAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + if (Interlocked.CompareExchange(ref this._isInitialized, 1, 0) == 1) + { + return; + } + + await Task.WhenAll(s_mutableScopes.Select(scopeName => ReadScopeAsync(scopeName).AsTask())).ConfigureAwait(false); + + async ValueTask ReadScopeAsync(string scopeName) + { + HashSet keys = await context.ReadStateKeysAsync(scopeName).ConfigureAwait(false); + foreach (string key in keys) + { + object? value = await context.ReadStateAsync(key, scopeName).ConfigureAwait(false); + this._scopes.Set(key, value.ToFormulaValue(), scopeName); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs new file mode 100644 index 0000000000..7bcf0b9868 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Reflection; + +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; + +internal delegate ValueTask DelegateAction(IWorkflowContext context, CancellationToken cancellationToken); + +internal sealed class DelegateActionExecutor : ReflectingExecutor, IMessageHandler +{ + private readonly DelegateAction? _action; + + public DelegateActionExecutor(string actionId, DelegateAction? action = null) + : base(actionId) + { + this._action = action; + } + + public async ValueTask HandleAsync(DeclarativeExecutorResult message, IWorkflowContext context) + { + if (this._action is not null) + { + await this._action.Invoke(context, default).ConfigureAwait(false); + } + + await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id)).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs new file mode 100644 index 0000000000..c33b76a3d6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -0,0 +1,510 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Linq; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; + +internal sealed class WorkflowActionVisitor : DialogActionVisitor +{ + private readonly WorkflowBuilder _workflowBuilder; + private readonly DeclarativeWorkflowModel _workflowModel; + private readonly DeclarativeWorkflowOptions _workflowOptions; + private readonly DeclarativeWorkflowState _workflowState; + + public WorkflowActionVisitor( + Executor rootAction, + DeclarativeWorkflowState state, + DeclarativeWorkflowOptions options) + { + this._workflowBuilder = new WorkflowBuilder(rootAction); + this._workflowModel = new DeclarativeWorkflowModel(rootAction); + this._workflowOptions = options; + this._workflowState = state; + } + + public bool HasUnsupportedActions { get; private set; } + + public Workflow Complete() + { + // Process the cached links + this._workflowModel.ConnectNodes(this._workflowBuilder); + + // Build final workflow + return this._workflowBuilder.Build(); + } + + protected override void Visit(ActionScope item) + { + this.Trace(item); + + string parentId = GetParentId(item); + + // Handle case where root element is its own parent + if (item.Id.Equals(parentId)) + { + parentId = RootId(parentId); + } + + this.ContinueWith(this.CreateStep(item.Id.Value), parentId, condition: null, CompletionHandler); + + // Complete the action scope. + void CompletionHandler() + { + if (this._workflowModel.GetDepth(item.Id.Value) > 1) + { + string completionId = this.ContinuationFor(item.Id.Value); // End scope + this._workflowModel.AddLinkFromPeer(item.Id.Value, completionId); // Connect with final action + this._workflowModel.AddLink(completionId, PostId(parentId)); // Merge with parent scope + } + } + } + + public override void VisitConditionItem(ConditionItem item) + { + this.Trace(item); + + ConditionGroupExecutor? conditionGroup = this._workflowModel.LocateParent(item.GetParentId()); + if (conditionGroup is not null) + { + string stepId = ConditionGroupExecutor.Steps.Item(conditionGroup.Model, item); + string parentId = GetParentId(item); + this._workflowModel.AddNode(this.CreateStep(stepId), parentId, CompletionHandler); + + base.VisitConditionItem(item); + + // Complete the condition item. + void CompletionHandler() + { + string completionId = this.ContinuationFor(stepId); // End items + this._workflowModel.AddLink(completionId, PostId(conditionGroup.Id)); // Merge with parent scope + + // Merge link when no action group is defined + if (!item.Actions.Any()) + { + this._workflowModel.AddLink(stepId, completionId); + } + } + } + } + + protected override void Visit(ConditionGroup item) + { + this.Trace(item); + + ConditionGroupExecutor action = new(item, this._workflowState); + this.ContinueWith(action); + this.ContinuationFor(action.Id, action.ParentId); + + string? lastConditionItemId = null; + foreach (ConditionItem conditionItem in item.Conditions) + { + // Create conditional link for conditional action + lastConditionItemId = ConditionGroupExecutor.Steps.Item(item, conditionItem); + this._workflowModel.AddLink(action.Id, lastConditionItemId, (result) => action.IsMatch(conditionItem, result)); + + conditionItem.Accept(this); + } + + if (item.ElseActions?.Actions.Length > 0) + { + if (lastConditionItemId is not null) + { + // Create clean start for else action from prior conditions + this.RestartAfter(lastConditionItemId, action.Id); + } + // Create conditional link for else action + string stepId = ConditionGroupExecutor.Steps.Else(item); + this._workflowModel.AddLink(action.Id, stepId, (result) => action.IsElse(result)); + } + } + + protected override void Visit(GotoAction item) + { + this.Trace(item); + + string parentId = GetParentId(item); + this.ContinueWith(this.CreateStep(item.Id.Value), parentId); + this._workflowModel.AddLink(item.Id.Value, item.ActionId.Value); + this.RestartAfter(item.Id.Value, parentId); + } + + protected override void Visit(Foreach item) + { + this.Trace(item); + + ForeachExecutor action = new(item, this._workflowState); + string loopId = ForeachExecutor.Steps.Next(action.Id); + this.ContinueWith(action, condition: null, CompletionHandler); // Foreach + this.ContinueWith(this.CreateStep(loopId, action.TakeNextAsync), action.Id); // Loop Increment + string continuationId = this.ContinuationFor(action.Id, action.ParentId); // Action continuation + this._workflowModel.AddLink(loopId, continuationId, (_) => !action.HasValue); + DelegateActionExecutor startAction = this.CreateStep(ForeachExecutor.Steps.Start(action.Id)); // Action start + this._workflowModel.AddNode(startAction, action.Id); + this._workflowModel.AddLink(loopId, startAction.Id, (_) => action.HasValue); + + void CompletionHandler() + { + string endActionsId = ForeachExecutor.Steps.End(action.Id); // Loop continuation + this.ContinueWith(this.CreateStep(endActionsId, action.ResetAsync), action.Id); + this._workflowModel.AddLink(endActionsId, loopId); + } + } + + protected override void Visit(BreakLoop item) + { + this.Trace(item); + + ForeachExecutor? loopExecutor = this._workflowModel.LocateParent(item.GetParentId()); + if (loopExecutor is not null) + { + string parentId = GetParentId(item); + this.ContinueWith(this.CreateStep(item.Id.Value), parentId); + this._workflowModel.AddLink(item.Id.Value, PostId(loopExecutor.Id)); + this.RestartAfter(item.Id.Value, parentId); + } + } + + protected override void Visit(ContinueLoop item) + { + this.Trace(item); + + ForeachExecutor? loopExecutor = this._workflowModel.LocateParent(item.GetParentId()); + if (loopExecutor is not null) + { + string parentId = GetParentId(item); + this.ContinueWith(this.CreateStep(item.Id.Value), parentId); + this._workflowModel.AddLink(item.Id.Value, ForeachExecutor.Steps.Next(loopExecutor.Id)); + this.RestartAfter(item.Id.Value, parentId); + } + } + + protected override void Visit(EndConversation item) + { + this.Trace(item); + + string parentId = GetParentId(item); + this.ContinueWith(this.CreateStep(item.Id.Value), parentId); + this.RestartAfter(item.Id.Value, parentId); + } + + protected override void Visit(AnswerQuestionWithAI item) + { + this.Trace(item); + + this.ContinueWith(new AnswerQuestionWithAIExecutor(item, this._workflowOptions.AgentProvider, this._workflowState)); + } + + protected override void Visit(SetVariable item) + { + this.Trace(item); + + this.ContinueWith(new SetVariableExecutor(item, this._workflowState)); + } + + protected override void Visit(SetTextVariable item) + { + this.Trace(item); + + this.ContinueWith(new SetTextVariableExecutor(item, this._workflowState)); + } + + protected override void Visit(ClearAllVariables item) + { + this.Trace(item); + + this.ContinueWith(new ClearAllVariablesExecutor(item, this._workflowState)); + } + + protected override void Visit(ResetVariable item) + { + this.Trace(item); + + this.ContinueWith(new ResetVariableExecutor(item, this._workflowState)); + } + + protected override void Visit(EditTable item) + { + this.Trace(item); + + this.ContinueWith(new EditTableExecutor(item, this._workflowState)); + } + + protected override void Visit(EditTableV2 item) + { + this.Trace(item); + + this.ContinueWith(new EditTableV2Executor(item, this._workflowState)); + } + + protected override void Visit(ParseValue item) + { + this.Trace(item); + + this.ContinueWith(new ParseValueExecutor(item, this._workflowState)); + } + + protected override void Visit(SendActivity item) + { + this.Trace(item); + + this.ContinueWith(new SendActivityExecutor(item, this._workflowState)); + } + + #region Not supported + + protected override void Visit(DeleteActivity item) + { + this.NotSupported(item); + } + + protected override void Visit(GetActivityMembers item) + { + this.NotSupported(item); + } + + protected override void Visit(UpdateActivity item) + { + this.NotSupported(item); + } + + protected override void Visit(ActivateExternalTrigger item) + { + this.NotSupported(item); + } + + protected override void Visit(DisableTrigger item) + { + this.NotSupported(item); + } + + protected override void Visit(WaitForConnectorTrigger item) + { + this.NotSupported(item); + } + + protected override void Visit(InvokeConnectorAction item) + { + this.NotSupported(item); + } + + protected override void Visit(InvokeCustomModelAction item) + { + this.NotSupported(item); + } + + protected override void Visit(InvokeFlowAction item) + { + this.NotSupported(item); + } + + protected override void Visit(InvokeAIBuilderModelAction item) + { + this.NotSupported(item); + } + + protected override void Visit(InvokeSkillAction item) + { + this.NotSupported(item); + } + + protected override void Visit(AdaptiveCardPrompt item) + { + this.NotSupported(item); + } + + protected override void Visit(Question item) + { + this.NotSupported(item); + } + + protected override void Visit(CSATQuestion item) + { + this.NotSupported(item); + } + + protected override void Visit(OAuthInput item) + { + this.NotSupported(item); + } + + protected override void Visit(BeginDialog item) + { + this.NotSupported(item); + } + + protected override void Visit(UnknownDialogAction item) + { + this.NotSupported(item); + } + + protected override void Visit(EndDialog item) + { + this.NotSupported(item); + } + + protected override void Visit(RepeatDialog item) + { + this.NotSupported(item); + } + + protected override void Visit(ReplaceDialog item) + { + this.NotSupported(item); + } + + protected override void Visit(CancelAllDialogs item) + { + this.NotSupported(item); + } + + protected override void Visit(CancelDialog item) + { + this.NotSupported(item); + } + + protected override void Visit(EmitEvent item) + { + this.NotSupported(item); + } + + protected override void Visit(GetConversationMembers item) + { + this.NotSupported(item); + } + + protected override void Visit(HttpRequestAction item) + { + this.NotSupported(item); + } + + protected override void Visit(RecognizeIntent item) + { + this.NotSupported(item); + } + + protected override void Visit(TransferConversation item) + { + this.NotSupported(item); + } + + protected override void Visit(TransferConversationV2 item) + { + this.NotSupported(item); + } + + protected override void Visit(SignOutUser item) + { + this.NotSupported(item); + } + + protected override void Visit(LogCustomTelemetryEvent item) + { + this.NotSupported(item); + } + + protected override void Visit(DisconnectedNodeContainer item) + { + this.NotSupported(item); + } + + protected override void Visit(CreateSearchQuery item) + { + this.NotSupported(item); + } + + protected override void Visit(SearchKnowledgeSources item) + { + this.NotSupported(item); + } + + protected override void Visit(SearchAndSummarizeWithCustomModel item) + { + this.NotSupported(item); + } + + protected override void Visit(SearchAndSummarizeContent item) + { + this.NotSupported(item); + } + + #endregion + + private void ContinueWith( + WorkflowActionExecutor executor, + Func? condition = null, + Action? completionHandler = null) + { + executor.Logger = this._workflowOptions.LoggerFactory.CreateLogger(executor.Id); + this.ContinueWith(executor, executor.ParentId, condition, completionHandler); + } + + private void ContinueWith( + Executor executor, + string parentId, + Func? condition = null, + Action? completionHandler = null) + { + this._workflowModel.AddNode(executor, parentId, completionHandler); + this._workflowModel.AddLinkFromPeer(parentId, executor.Id, condition); + } + + public static string RootId(string? actionId) => $"root_{actionId ?? "workflow"}"; + + private static string PostId(string actionId) => $"{actionId}_Post"; + + private static string GetParentId(BotElement item) => + item.GetParentId() ?? + throw new DeclarativeModelException($"Missing parent ID for action element: {item.GetId()} [{item.GetType().Name}]."); + + private string ContinuationFor(string parentId) => this.ContinuationFor(parentId, parentId); + + private string ContinuationFor(string actionId, string parentId) + { + actionId = PostId(actionId); + this._workflowModel.AddNode(this.CreateStep(actionId), parentId); + return actionId; + } + + private void RestartAfter(string actionId, string parentId) => + this._workflowModel.AddNode(this.CreateStep($"{actionId}_Continue"), parentId); + + private DelegateActionExecutor CreateStep(string actionId, DelegateAction? stepAction = null) + { + DelegateActionExecutor stepExecutor = new(actionId, stepAction); + + return stepExecutor; + } + + private void NotSupported(DialogAction item) + { + Debug.WriteLine($"> UNKNOWN: {new string('\t', this._workflowModel.GetDepth(item.GetParentId()))}{FormatItem(item)} => {FormatParent(item)}"); + this.HasUnsupportedActions = true; + } + + private void Trace(BotElement item) + { + Debug.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(item.GetParentId()))}{FormatItem(item)} => {FormatParent(item)}"); + } + + private void Trace(DialogAction item) + { + string? parentId = item.GetParentId(); + if (item.Id.Equals(parentId ?? string.Empty)) + { + parentId = RootId(parentId); + } + Debug.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(parentId))}{FormatItem(item)} => {FormatParent(item)}"); + } + + private static string FormatItem(BotElement element) => $"{element.GetType().Name} ({element.GetId()})"; + + private static string FormatParent(BotElement element) => + element.Parent is null ? + throw new DeclarativeModelException($"Undefined parent for {element.GetType().Name} that is member of {element.GetId()}.") : + $"{element.Parent.GetType().Name} ({element.GetParentId()})"; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs new file mode 100644 index 0000000000..0769786420 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; + +internal sealed class WorkflowElementWalker : BotElementWalker +{ + private readonly WorkflowActionVisitor _visitor; + + public WorkflowElementWalker(BotElement rootElement, WorkflowActionVisitor visitor) + { + this._visitor = visitor; + this.Visit(rootElement); + } + + public Workflow GetWorkflow() => 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/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj new file mode 100644 index 0000000000..634cd93836 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj @@ -0,0 +1,41 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + alpha + + + + true + true + true + + + + + + + Microsoft Agent Workflow Framework + Contains the Microsoft Agent Declarative Workflow Framework. + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs new file mode 100644 index 0000000000..15806e38ac --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, WorkflowAgentProvider agentProvider, DeclarativeWorkflowState state) + : DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + StringExpression userInputExpression = Throw.IfNull(this.Model.UserInput, $"{nameof(this.Model)}.{nameof(this.Model.UserInput)}"); + + string agentInstructions = this.State.Format(this.Model.AdditionalInstructions) ?? string.Empty; + // ISSUE #485 - Agent identifier embedded in instructions until updated OM is available. + string agentId; + string? additionalInstructions = null; + int delimiterIndex = agentInstructions.IndexOf(','); + if (delimiterIndex < 0) + { + agentId = agentInstructions.Trim(); + } + else + { + agentId = agentInstructions.Substring(0, delimiterIndex).Trim(); + additionalInstructions = agentInstructions.Substring(delimiterIndex + 1).Trim(); + } + + AIAgent agent = await agentProvider.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false); + + string? userInput = null; + if (this.Model.UserInput is not null) + { + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(userInputExpression); + userInput = expressionResult.Value; + } + + ChatClientAgentRunOptions options = + new( + new ChatOptions() + { + Instructions = additionalInstructions, + }); + + FormulaValue conversationValue = + this.Model.AutoSend ? // ISSUE #485: Conversation implicitly managed until updated OM is available. + this.State.GetConversationId() : + this.State.GetInternalConversationId(); + + string? conversationId = null; + if (conversationValue is StringValue stringValue) + { + await AssignConversationId(stringValue.Value).ConfigureAwait(false); + } + + AgentThread agentThread = new() { ConversationId = conversationId }; + IAsyncEnumerable agentUpdates = + !string.IsNullOrWhiteSpace(userInput) ? + agent.RunStreamingAsync(userInput, agentThread, options, cancellationToken) : + agent.RunStreamingAsync(agentThread, options, cancellationToken); + + string? messageId = null; + List agentResponseUpdates = new(0x400); + await foreach (AgentRunResponseUpdate update in agentUpdates.ConfigureAwait(false)) + { + agentResponseUpdates.Add(update); + messageId ??= update.MessageId; + await AssignConversationId(((ChatResponseUpdate?)update.RawRepresentation)?.ConversationId).ConfigureAwait(false); + if (this.Model.AutoSend) + { + await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update)).ConfigureAwait(false); + } + } + + AgentRunResponse agentResponse = agentResponseUpdates.ToAgentRunResponse(); + + ChatMessage response = agentResponse.Messages.Last(); + await this.State.SetLastMessageAsync(context, response).ConfigureAwait(false); + if (this.Model.AutoSend) + { + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false); + } + + // Assign conversation ID if it wasn't already assigned. + if (conversationValue is not StringValue && conversationId is not null) + { + if (this.Model.AutoSend) // ISSUE #485: Conversation implicitly managed until updated OM is available. + { + await this.State.SetConversationIdAsync(context, conversationId).ConfigureAwait(false); + } + else + { + await this.State.SetInternalConversationIdAsync(context, conversationId).ConfigureAwait(false); + } + } + + PropertyPath? variablePath = this.Model.Variable?.Path; + if (variablePath is not null) + { + await this.AssignAsync(variablePath, response.ToRecord(), context).ConfigureAwait(false); + } + + return default; + + async ValueTask AssignConversationId(string? assignValue) + { + if (assignValue != null && conversationId == null) + { + conversationId = assignValue; + await context.AddEventAsync(new ConversationUpdateEvent(this.Id, conversationId)).ConfigureAwait(false); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs new file mode 100644 index 0000000000..080a985db4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class ClearAllVariablesExecutor(ClearAllVariables model, DeclarativeWorkflowState state) + : DeclarativeActionExecutor(model, state) +{ + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + EvaluationResult variablesResult = this.State.ExpressionEngine.GetValue(this.Model.Variables); + + variablesResult.Value.Handle(new ScopeHandler(this.Id, this.State)); + + return default; + } + + private sealed class ScopeHandler(string executorId, DeclarativeWorkflowState state) : IEnumVariablesToClearHandler + { + public void HandleAllGlobalVariables() + { + this.ClearAll(VariableScopeNames.Global); + } + + public void HandleConversationHistory() + { + // Not supported.... + } + + public void HandleConversationScopedVariables() + { + this.ClearAll(WorkflowScopes.DefaultScopeName); + } + + public void HandleUnknownValue() + { + // No scope to clear for unknown values. + } + + public void HandleUserScopedVariables() + { + // Not supported.... + } + + private void ClearAll(string scope) + { + state.Reset(scope); + Debug.WriteLine( + $""" + STATE: {this.GetType().Name} [{executorId}] + SCOPE: {scope} + """); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs new file mode 100644 index 0000000000..a3cbfb9ff4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class ConditionGroupExecutor : DeclarativeActionExecutor +{ + public static class Steps + { + public static string Item(ConditionGroup model, ConditionItem conditionItem) + { + if (conditionItem.Id is not null) + { + return conditionItem.Id; + } + int index = model.Conditions.IndexOf(conditionItem); + return $"{model.Id}_Items{index}"; + } + + public static string Else(ConditionGroup model) => model.ElseActions.Id.Value ?? $"{model.Id}_Else"; + } + + public ConditionGroupExecutor(ConditionGroup model, DeclarativeWorkflowState state) + : base(model, state) + { + } + + public bool IsMatch(ConditionItem conditionItem, object? result) + { + if (result is not DeclarativeExecutorResult message) + { + return false; + } + + return string.Equals(Steps.Item(this.Model, conditionItem), message.Result as string, StringComparison.Ordinal); + } + + public bool IsElse(object? result) + { + if (result is not DeclarativeExecutorResult message) + { + return false; + } + + return string.Equals(Steps.Else(this.Model), message.Result as string, StringComparison.Ordinal); + } + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + for (int index = 0; index < this.Model.Conditions.Length; ++index) + { + ConditionItem conditionItem = this.Model.Conditions[index]; + if (conditionItem.Condition is null) + { + continue; // Skip if no condition is defined + } + + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(conditionItem.Condition); + if (expressionResult.Value) + { + return Steps.Item(this.Model, conditionItem); + } + } + + return Steps.Else(this.Model); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs new file mode 100644 index 0000000000..0c09b270fe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class EditTableExecutor(EditTable model, DeclarativeWorkflowState state) : DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + PropertyPath variablePath = Throw.IfNull(this.Model.ItemsVariable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.ItemsVariable)}"); + + FormulaValue table = this.State.Get(variablePath); + if (table is not TableValue tableValue) + { + throw this.Exception($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); + } + + TableChangeType changeType = this.Model.ChangeType.Value; + switch (this.Model.ChangeType.Value) + { + case TableChangeType.Add: + ValueExpression addItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); + EvaluationResult addResult = this.State.ExpressionEngine.GetValue(addItemValue); + RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), addResult.Value.ToFormulaValue()); + await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); + await this.AssignAsync(variablePath, newRecord, context).ConfigureAwait(false); + break; + case TableChangeType.Remove: + ValueExpression removeItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); + EvaluationResult removeResult = this.State.ExpressionEngine.GetValue(removeItemValue); + if (removeResult.Value is TableDataValue removeItemTable) + { + await tableValue.RemoveAsync(removeItemTable?.Values.Select(row => row.ToRecordValue()), all: true, cancellationToken).ConfigureAwait(false); + await this.AssignAsync(variablePath, RecordValue.Empty(), context).ConfigureAwait(false); + } + break; + case TableChangeType.Clear: + await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); + await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + break; + case TableChangeType.TakeFirst: + RecordValue? firstRow = tableValue.Rows.FirstOrDefault()?.Value; + if (firstRow is not null) + { + await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); + await this.AssignAsync(variablePath, firstRow, context).ConfigureAwait(false); + } + break; + case TableChangeType.TakeLast: + RecordValue? lastRow = tableValue.Rows.LastOrDefault()?.Value; + if (lastRow is not null) + { + await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); + await this.AssignAsync(variablePath, lastRow, context).ConfigureAwait(false); + } + break; + } + + return default; + + static RecordValue BuildRecord(RecordType recordType, FormulaValue value) + { + return FormulaValue.NewRecordFromFields(recordType, GetValues()); + + IEnumerable GetValues() + { + 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/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs new file mode 100644 index 0000000000..c18e87dce6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class EditTableV2Executor(EditTableV2 model, DeclarativeWorkflowState state) : DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + PropertyPath variablePath = Throw.IfNull(this.Model.ItemsVariable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.ItemsVariable)}"); + + FormulaValue table = this.State.Get(variablePath); + if (table is not TableValue tableValue) + { + throw this.Exception($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); + } + + EditTableOperation? changeType = this.Model.ChangeType; + if (changeType is AddItemOperation addItemOperation) + { + ValueExpression addItemValue = Throw.IfNull(addItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(addItemValue); + RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), expressionResult.Value.ToFormulaValue()); + await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); + await this.AssignAsync(variablePath, newRecord, context).ConfigureAwait(false); + } + else if (changeType is ClearItemsOperation) + { + await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); + await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + } + else if (changeType is RemoveItemOperation removeItemOperation) + { + ValueExpression removeItemValue = Throw.IfNull(removeItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(removeItemValue); + if (expressionResult.Value.ToFormulaValue() is TableValue removeItemTable) + { + await tableValue.RemoveAsync(removeItemTable?.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false); + await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + } + } + else if (changeType is TakeLastItemOperation) + { + RecordValue? lastRow = tableValue.Rows.LastOrDefault()?.Value; + if (lastRow is not null) + { + await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); + await this.AssignAsync(variablePath, lastRow, context).ConfigureAwait(false); + } + } + else if (changeType is TakeFirstItemOperation) + { + RecordValue? firstRow = tableValue.Rows.FirstOrDefault()?.Value; + if (firstRow is not null) + { + await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); + await this.AssignAsync(variablePath, firstRow, context).ConfigureAwait(false); + } + } + + return default; + + static RecordValue BuildRecord(RecordType recordType, FormulaValue value) + { + return FormulaValue.NewRecordFromFields(recordType, GetValues()); + + IEnumerable GetValues() + { + 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/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs new file mode 100644 index 0000000000..8d3850cdf5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class ForeachExecutor : DeclarativeActionExecutor +{ + public static class Steps + { + public static string Start(string id) => $"{id}_{nameof(Start)}"; + public static string Next(string id) => $"{id}_{nameof(Next)}"; + public static string End(string id) => $"{id}_{nameof(End)}"; + } + + private int _index; + private FormulaValue[] _values; + + public ForeachExecutor(Foreach model, DeclarativeWorkflowState state) + : base(model, state) + { + this._values = []; + } + + public bool HasValue { get; private set; } + + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + this._index = 0; + + if (this.Model.Items is null) + { + this._values = []; + this.HasValue = false; + } + else + { + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(this.Model.Items); + if (expressionResult.Value is TableDataValue tableValue) + { + this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormulaValue())]; + } + else + { + this._values = [expressionResult.Value.ToFormulaValue()]; + } + } + + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); + + return default; + } + + public async ValueTask TakeNextAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + if (this.HasValue = this._index < this._values.Length) + { + FormulaValue value = this._values[this._index]; + + await this.State.SetAsync(Throw.IfNull(this.Model.Value), value, context).ConfigureAwait(false); + + if (this.Model.Index is not null) + { + await this.State.SetAsync(this.Model.Index.Path, FormulaValue.New(this._index), context).ConfigureAwait(false); + } + + this._index++; + } + } + + public async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + this.State.Reset(Throw.IfNull(this.Model.Value)); + if (this.Model.Index is not null) + { + this.State.Reset(this.Model.Index); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs new file mode 100644 index 0000000000..377c472a20 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -0,0 +1,77 @@ + +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class ParseValueExecutor(ParseValue model, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); + ValueExpression valueExpression = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); + + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(valueExpression); + + FormulaValue? parsedResult = null; + + if (expressionResult.Value is RecordDataValue recordValue) + { + parsedResult = recordValue.ToFormulaValue(); + } + else if (expressionResult.Value is StringDataValue stringValue) + { + if (string.IsNullOrWhiteSpace(stringValue.Value)) + { + parsedResult = FormulaValue.NewBlank(expressionResult.Value.GetDataType().ToFormulaType()); + } + 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 this.Exception("Unable to parse value."); + } + + await this.AssignAsync(variablePath, parsedResult, context).ConfigureAwait(false); + + return default; + + RecordValue ParseRecord(RecordDataType recordType, string rawText) + { + string jsonText = rawText.TrimJsonDelimiter(); + JsonDocument json = JsonDocument.Parse(jsonText); + JsonElement currentElement = json.RootElement; + try + { + return recordType.ParseRecord(currentElement); + } + catch (Exception exception) + { + throw this.Exception("Failed to parse value.", exception); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs new file mode 100644 index 0000000000..953233762c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class ResetVariableExecutor(ResetVariable model, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) +{ + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + PropertyPath variablePath = Throw.IfNull(this.Model.Variable, $"{nameof(this.Model)}.{nameof(model.Variable)}"); + + this.State.Reset(this.Model.Variable); + Debug.WriteLine( + $""" + STATE: {this.GetType().Name} [{this.Id}] + NAME: {this.Model.Variable!.Format()} + """); + + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs new file mode 100644 index 0000000000..2c581668a4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class SendActivityExecutor(SendActivity model, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + if (this.Model.Activity is MessageActivityTemplate messageActivity) + { + StringBuilder templateBuilder = new(); + if (!string.IsNullOrEmpty(messageActivity.Summary)) + { + templateBuilder.AppendLine($"\t{messageActivity.Summary}"); + } + + string? activityText = this.State.Format(messageActivity.Text)?.Trim(); + templateBuilder.AppendLine(activityText); + + AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, templateBuilder.ToString().Trim())]); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + } + + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs new file mode 100644 index 0000000000..2ee97a7b25 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class SetTextVariableExecutor(SetTextVariable model, DeclarativeWorkflowState state) + : DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); + + if (this.Model.Value is null) + { + await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + } + else + { + FormulaValue expressionResult = FormulaValue.New(this.State.Format(this.Model.Value)); + + await this.AssignAsync(variablePath, expressionResult, context).ConfigureAwait(false); + } + + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs new file mode 100644 index 0000000000..e1c6ec8857 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class SetVariableExecutor(SetVariable model, DeclarativeWorkflowState state) + : DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); + + if (this.Model.Value is null) + { + await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + } + else + { + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(this.Model.Value); + + await this.AssignAsync(variablePath, expressionResult.Value.ToFormulaValue(), context).ConfigureAwait(false); + } + + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs new file mode 100644 index 0000000000..f025164eae --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +internal static class RecalcEngineFactory +{ + public static RecalcEngine Create( + int? maximumExpressionLength = null, + int? maximumCallDepth = null) + { + RecalcEngine engine = new(CreateConfig()); + + foreach (string scopeName in VariableScopeNames.AllScopes) + { + engine.UpdateVariable(scopeName, RecordValue.Empty()); + } + + return engine; + + 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/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs new file mode 100644 index 0000000000..83096bcb13 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.SystemVariables; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +internal static class SystemScope +{ + public static class Names + { + public const string Activity = nameof(Activity); + public const string Bot = nameof(Bot); + public const string Conversation = nameof(Conversation); + public const string ConversationId = nameof(SystemVariables.ConversationId); + public const string InternalId = nameof(InternalId); + public const string LastMessage = nameof(LastMessage); + public const string LastMessageId = nameof(SystemVariables.LastMessageId); + public const string LastMessageText = nameof(SystemVariables.LastMessageText); + public const string Recognizer = nameof(Recognizer); + public const string User = nameof(User); + public const string UserLanguage = nameof(UserLanguage); + } + + public static ImmutableHashSet AllNames { get; } = GetNames().ToImmutableHashSet(); + + public static IEnumerable GetNames() + { + yield return Names.Activity; + yield return Names.Bot; + yield return Names.Conversation; + yield return Names.ConversationId; + yield return Names.InternalId; + yield return Names.LastMessage; + yield return Names.LastMessageId; + yield return Names.LastMessageText; + yield return Names.Recognizer; + yield return Names.User; + yield return Names.UserLanguage; + } + + public static void InitializeSystem(this WorkflowScopes scopes) + { + scopes.Set(Names.Activity, RecordValue.Empty(), VariableScopeNames.System); + scopes.Set(Names.Bot, RecordValue.Empty(), VariableScopeNames.System); + + scopes.Set(Names.LastMessage, FormulaType.String.NewBlank(), VariableScopeNames.System); + Set(Names.LastMessageId); + Set(Names.LastMessageText); + + scopes.Set( + Names.Conversation, + RecordValue.NewRecordFromFields( + new NamedValue("Id", FormulaType.String.NewBlank()), + new NamedValue("LocalTimeZone", FormulaValue.New(TimeZoneInfo.Local.StandardName)), + new NamedValue("LocalTimeZoneOffset", FormulaValue.New(TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow))), + new NamedValue("InTestMode", FormulaValue.New(false))), + VariableScopeNames.System); + scopes.Set(Names.ConversationId, FormulaType.String.NewBlank(), VariableScopeNames.System); + scopes.Set(Names.InternalId, FormulaType.String.NewBlank(), VariableScopeNames.System); + + scopes.Set( + Names.Recognizer, + RecordValue.NewRecordFromFields( + new NamedValue("Id", FormulaType.String.NewBlank()), + new NamedValue("Text", FormulaType.String.NewBlank())), + VariableScopeNames.System); + + scopes.Set( + Names.User, + RecordValue.NewRecordFromFields( + new NamedValue("Language", StringValue.New(CultureInfo.CurrentCulture.TwoLetterISOLanguageName))), + VariableScopeNames.System); + scopes.Set(Names.UserLanguage, StringValue.New(CultureInfo.CurrentCulture.TwoLetterISOLanguageName), VariableScopeNames.System); + + void Set(string key, string? value = null) + { + if (string.IsNullOrEmpty(value)) + { + scopes.Set(key, FormulaType.String.NewBlank(), VariableScopeNames.System); + } + else + { + scopes.Set(key, FormulaValue.New(value), VariableScopeNames.System); + } + } + } + + public static FormulaValue GetConversationId(this DeclarativeWorkflowState state) => + state.Get(VariableScopeNames.System, Names.ConversationId); + + public static async ValueTask SetConversationIdAsync(this DeclarativeWorkflowState state, IWorkflowContext context, string conversationId) + { + RecordValue conversation = (RecordValue)state.Get(VariableScopeNames.System, Names.Conversation); + conversation.UpdateField("Id", FormulaValue.New(conversationId)); + await state.SetAsync(VariableScopeNames.System, Names.Conversation, conversation, context).ConfigureAwait(false); + await state.SetAsync(VariableScopeNames.System, Names.ConversationId, FormulaValue.New(conversationId), context).ConfigureAwait(false); + } + + public static FormulaValue GetInternalConversationId(this DeclarativeWorkflowState state) => + state.Get(VariableScopeNames.System, Names.InternalId); + + public static ValueTask SetInternalConversationIdAsync(this DeclarativeWorkflowState state, IWorkflowContext context, string conversationId) => + state.SetAsync(VariableScopeNames.System, Names.InternalId, FormulaValue.New(conversationId), context); + + public static async ValueTask SetLastMessageAsync(this DeclarativeWorkflowState state, IWorkflowContext context, ChatMessage message) + { + await state.SetAsync(VariableScopeNames.System, Names.LastMessage, message.ToRecord(), context).ConfigureAwait(false); + await state.SetAsync(VariableScopeNames.System, Names.LastMessageId, message.MessageId is null ? FormulaValue.NewBlank(FormulaType.String) : FormulaValue.New(message.MessageId), context).ConfigureAwait(false); + await state.SetAsync(VariableScopeNames.System, Names.LastMessageText, FormulaValue.New(message.Text), context).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs new file mode 100644 index 0000000000..5bf22d92cf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.Bot.ObjectModel.Analysis; +using Microsoft.Bot.ObjectModel.PowerFx; +using Microsoft.Extensions.Configuration; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +internal static class WorkflowDiagnostics +{ + private static readonly WorkflowFeatureConfiguration s_semanticFeatureConfig = new(); + + public static void Initialize(this WorkflowScopes scopes, TElement workflowElement, IConfiguration? configuration) where TElement : BotElement, IDialogBase + { + scopes.InitializeSystem(); + + SemanticModel semanticModel = workflowElement.GetSemanticModel(new PowerFxExpressionChecker(s_semanticFeatureConfig), s_semanticFeatureConfig); + scopes.InitializeEnvironment(semanticModel, configuration); + scopes.InitializeDefaults(semanticModel, workflowElement.SchemaName.Value); + } + + private static void InitializeEnvironment(this WorkflowScopes scopes, SemanticModel semanticModel, IConfiguration? configuration) + { + foreach (string variableName in semanticModel.GetAllEnvironmentVariablesReferencedInTheBot()) + { + string? environmentValue = configuration is not null ? configuration[variableName] : Environment.GetEnvironmentVariable(variableName); + FormulaValue variableValue = string.IsNullOrEmpty(environmentValue) ? FormulaType.String.NewBlank() : FormulaValue.New(environmentValue); + scopes.Set(variableName, variableValue, VariableScopeNames.Environment); + } + } + + private static void InitializeDefaults(this WorkflowScopes scopes, SemanticModel semanticModel, string schemaName) + { + foreach (VariableInformationDiagnostic variableDiagnostic in semanticModel.GetVariables(schemaName).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())) + { + if (variableDiagnostic is null || variableDiagnostic?.Path?.VariableName is null) + { + continue; + } + + FormulaValue defaultValue = variableDiagnostic.ConstantValue?.ToFormulaValue() ?? variableDiagnostic.Type.NewBlank(); + + if (variableDiagnostic.Path.VariableScopeName?.Equals(VariableScopeNames.System, StringComparison.OrdinalIgnoreCase) ?? false) + { + if (!SystemScope.AllNames.Contains(variableDiagnostic.Path.VariableName)) + { + throw new DeclarativeModelException($"Variable '{variableDiagnostic.Path.VariableName}' is not a supported system variable."); + } + } + + scopes.Set(variableDiagnostic.Path.VariableName, defaultValue, variableDiagnostic.Path.VariableScopeName ?? WorkflowScopes.DefaultScopeName); + } + } + + private sealed class WorkflowFeatureConfiguration : IFeatureConfiguration + { + public long GetInt64Value(string settingName, long defaultValue) => defaultValue; + + public string GetStringValue(string settingName, string defaultValue) => defaultValue; + + public bool IsEnvironmentFeatureEnabled(string featureName, bool defaultValue) => true; + + public bool IsTenantFeatureEnabled(string featureName, bool defaultValue) => true; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs new file mode 100644 index 0000000000..70b594e50d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -0,0 +1,343 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.Bot.ObjectModel.Exceptions; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +internal class WorkflowExpressionEngine : IExpressionEngine +{ + private readonly RecalcEngine _engine; + + public WorkflowExpressionEngine(RecalcEngine engine) + { + this._engine = engine; + } + + public EvaluationResult GetValue(BoolExpression boolean, WorkflowScopes? state = null) => this.GetValue(boolean, state, this.EvaluateScope); + + public EvaluationResult GetValue(BoolExpression boolean, RecordDataValue state) => this.GetValue(boolean, state, this.EvaluateState); + + public EvaluationResult GetValue(StringExpression expression, WorkflowScopes? state = null) => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(StringExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); + + public EvaluationResult GetValue(ValueExpression expression, WorkflowScopes? state = null) => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(ValueExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); + + public EvaluationResult GetValue(IntExpression expression, WorkflowScopes? state = null) => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(IntExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); + + public EvaluationResult GetValue(NumberExpression expression, WorkflowScopes? state = null) => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(NumberExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); + + public EvaluationResult GetValue(ObjectExpression expression, WorkflowScopes? state = null) 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, WorkflowScopes? state = null) => 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, WorkflowScopes? state = null) => 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, WorkflowScopes? state = null) 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(recordValue.Format(), 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 BlankValue) + { + return new EvaluationResult(default, expressionResult.Sensitivity); + } + + if (expressionResult.Value is not DecimalValue formulaValue) + { + throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.Number); + } + + return new EvaluationResult(Convert.ToInt64(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 BlankValue) + { + return new EvaluationResult(default, expressionResult.Sensitivity); + } + + if (expressionResult.Value is DecimalValue decimalValue) + { + return new EvaluationResult(Convert.ToDouble(decimalValue.Value), expressionResult.Sensitivity); + } + + if (expressionResult.Value is not NumberValue formulaValue) + { + throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.Float); + } + + 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.ToDataValue(), 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), + _ => 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 InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.TableFromEnumerable()); + } + + try + { + return new EvaluationResult(ObjectExpressionParser.Parse(formulaValue.ToRecord()), 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.Empty; + } + + if (value is not TableValue tableValue) + { + throw new InvalidExpressionOutputTypeException(value.GetDataType(), DataType.TableFromEnumerable()); + } + + TableDataValue tableDataValue = tableValue.ToTable(); + 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) + { + if (state is not null) + { + foreach (KeyValuePair kvp in state.Properties) + { + if (kvp.Value is RecordDataValue scopeRecord) + { + Bind(kvp.Key, scopeRecord.ToRecordValue()); + } + } + } + + return this.Evaluate(expression); + + void Bind(string scopeName, RecordValue stateRecord) + { + this._engine.DeleteFormula(scopeName); + this._engine.UpdateVariable(scopeName, stateRecord); + } + } + + private EvaluationResult EvaluateScope(ExpressionBase expression, WorkflowScopes? state = null) + { + state?.Bind(this._engine); + + 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/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs new file mode 100644 index 0000000000..b68e76d6f6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +/// +/// Contains all action scopes for a process. +/// +internal sealed class WorkflowScopes +{ + // ISSUE #488 - Update default scope for workflows to `Workflow` (instead of `Topic`) + public const string DefaultScopeName = VariableScopeNames.Topic; + + private readonly ImmutableDictionary _scopes; + + public WorkflowScopes() + { + this._scopes = VariableScopeNames.AllScopes.ToDictionary(scopeName => scopeName, scopeName => new WorkflowScope(scopeName)).ToImmutableDictionary(); + } + + public FormulaValue Get(string variableName, string? scopeName = null) + { + if (this._scopes[scopeName ?? WorkflowScopes.DefaultScopeName].TryGetValue(variableName, out FormulaValue? value)) + { + return value; + } + + return FormulaValue.NewBlank(); + } + + public void Clear(string scopeName) => this._scopes[scopeName].Reset(); + + public void Reset(string variableName, string? scopeName = null) => this._scopes[scopeName ?? WorkflowScopes.DefaultScopeName].Reset(variableName); + + public void Set(string variableName, FormulaValue value, string? scopeName = null) => this._scopes[scopeName ?? WorkflowScopes.DefaultScopeName][variableName] = value; + + public RecordValue BuildRecord(string scopeName) => this._scopes[scopeName].BuildRecord(); + + public RecordDataValue BuildState() + { + return DataValue.RecordFromFields(BuildStateFields()); + + IEnumerable> BuildStateFields() + { + foreach (KeyValuePair kvp in this._scopes) + { + yield return new(kvp.Key, kvp.Value.BuildState()); + } + } + } + + public void Bind(RecalcEngine engine, string? type = null) + { + if (type is not null) + { + Bind(type); + } + else + { + foreach (string scopeName in VariableScopeNames.AllScopes) + { + Bind(scopeName); + } + } + + void Bind(string scopeName) + { + RecordValue scopeRecord = this.BuildRecord(scopeName); + engine.DeleteFormula(scopeName); + engine.UpdateVariable(scopeName, scopeRecord); + } + } + + /// + /// The set of variables for a specific action scope. + /// + private sealed class WorkflowScope(string scopeName) : Dictionary + { + public string Name => scopeName; + + public void Reset() + { + foreach (string variableName in this.Keys.ToArray()) + { + this.Reset(variableName); + } + } + + public void Reset(string variableName) + { + if (this.TryGetValue(variableName, out FormulaValue? value)) + { + this[variableName] = value.Type.NewBlank(); + } + } + + 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.ToDataValue()); + } + + return recordBuilder.Build(); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs new file mode 100644 index 0000000000..d1f75d6b5d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Identity; +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Base class for workflow agent providers. +/// +public abstract class WorkflowAgentProvider +{ + /// + /// Asynchronously retrieves an AI agent by its unique identifier. + /// + /// The unique identifier of the AI agent to retrieve. Cannot be null or empty. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the associated + /// with the specified . Returns if no agent is found. + public abstract Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default); +} + +/// +/// Provides functionality to interact with Foundry agents within a specified project context. +/// +/// This class is used to retrieve and manage AI agents associated with a Foundry project. It requires a +/// project endpoint and credentials to authenticate requests. +/// The endpoint URL of the Foundry project. This must be a valid, non-null URI pointing to the project. +/// The credentials used to authenticate with the Foundry project. This must be a valid instance of . +/// An optional instance to be used for making HTTP requests. If not provided, a default client will be used. +public sealed class FoundryAgentProvider(string projectEndpoint, TokenCredential? projectCredentials = null, HttpClient? httpClient = null) : WorkflowAgentProvider +{ + private PersistentAgentsClient? _agentsClient; + + /// + public override async Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default) + { + AIAgent agent = await this.GetAgentsClient().GetAIAgentAsync(agentId, chatOptions: null, cancellationToken).ConfigureAwait(false); + + return agent; + } + + private PersistentAgentsClient GetAgentsClient() + { + if (this._agentsClient is null) + { + PersistentAgentsAdministrationClientOptions clientOptions = new(); + + if (httpClient is not null) + { + clientOptions.Transport = new HttpClientTransport(httpClient); + } + + PersistentAgentsClient newClient = new(projectEndpoint, projectCredentials ?? new DefaultAzureCredential(), clientOptions); + + Interlocked.CompareExchange(ref this._agentsClient, newClient, null); + } + + return this._agentsClient; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/AgentRunResponseEvent.cs b/dotnet/src/Microsoft.Agents.Workflows/AgentRunResponseEvent.cs new file mode 100644 index 0000000000..a323f6eb9b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/AgentRunResponseEvent.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.Agents.Workflows; + +/// +/// Event triggered when an agent run produces an update. +/// +public class AgentRunResponseEvent : ExecutorEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the executor that generated this event. + /// + public AgentRunResponseEvent(string executorId, AgentRunResponse response) : base(executorId, data: response) + { + this.Response = response; + } + + /// + /// Gets the content of the agent response. + /// + public AgentRunResponse Response { get; } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs index fcab3a1cd8..6d625df92d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs @@ -140,19 +140,19 @@ public ValueTask WriteStateAsync(ScopeId scopeId, string key, T? value) public async ValueTask PublishUpdatesAsync(IStepTracer? tracer) { - Dictionary>> updatesByScope = new(); + Dictionary>> updatesByScope = []; // Aggregate the updates for each scope foreach (UpdateKey key in this._queuedUpdates.Keys) { if (!updatesByScope.TryGetValue(key.ScopeId, out Dictionary>? scopeUpdates)) { - updatesByScope[key.ScopeId] = scopeUpdates = new(); + updatesByScope[key.ScopeId] = scopeUpdates = []; } if (!scopeUpdates.TryGetValue(key.Key, out List? stateUpdates)) { - scopeUpdates[key.Key] = stateUpdates = new(); + scopeUpdates[key.Key] = stateUpdates = []; } stateUpdates.Add(this._queuedUpdates[key]); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Executor.cs index f741a3515d..97519ec0ad 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Executor.cs @@ -94,7 +94,7 @@ internal MessageRouter Router if (!result.IsSuccess) { - throw new TargetInvocationException($"Error invoking handler for {message.GetType()}", result.Exception!); + throw new TargetInvocationException($"Error invoking handler for {message.GetType()}", result.Exception); } if (result.IsVoid) diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExecutorFailureEvent.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorFailureEvent.cs index 524549c7dd..b3a3168cd0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExecutorFailureEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExecutorFailureEvent.cs @@ -9,4 +9,11 @@ namespace Microsoft.Agents.Workflows; /// /// The unique identifier of the executor that has failed. /// The exception representing the error. -public sealed class ExecutorFailureEvent(string executorId, Exception? err) : ExecutorEvent(executorId, data: err); +public sealed class ExecutorFailureEvent(string executorId, Exception? err) + : ExecutorEvent(executorId, data: err) +{ + /// + /// The exception that caused the executor to fail. This may be null if no exception was thrown. + /// + public new Exception? Data => err; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs index c3278e41c4..39e6281577 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs @@ -29,8 +29,8 @@ public interface IWorkflowContext ValueTask SendMessageAsync(object message, string? targetId = null); /// - /// Reads a state value from the workflow's state store. If no scope is provided, the executor's private - /// scope is used. + /// Reads a state value from the workflow's state store. If no scope is provided, the executor's + /// default scope is used. /// /// The type of the state value. /// The key of the state value. diff --git a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs index 8892ac8cf0..15c4b9c8b2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs @@ -18,8 +18,8 @@ internal class InProcessRunnerContext : IRunnerContext { private StepContext _nextStep = new(); private readonly Dictionary _executorRegistrations; - private readonly Dictionary _executors = new(); - private readonly Dictionary _externalRequests = new(); + private readonly Dictionary _executors = []; + private readonly Dictionary _externalRequests = []; public InProcessRunnerContext(Workflow workflow, ILogger? logger = null) { @@ -96,7 +96,7 @@ public ValueTask PostAsync(ExternalRequest request) public bool CompleteRequest(string requestId) => this._externalRequests.Remove(requestId); - public readonly List QueuedEvents = new(); + public readonly List QueuedEvents = []; internal StateManager StateManager { get; } = new(); diff --git a/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs index 507b363a38..ddfe157604 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs @@ -83,7 +83,7 @@ internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorIsh sou List<(Func Predicate, HashSet OutgoingIndicies)> caseMap = this._caseMap; HashSet defaultIndicies = this._defaultIndicies; - return builder.AddFanOutEdge(source, CasePartitioner, this._executors.ToArray()); + return builder.AddFanOutEdge(source, CasePartitioner, [.. this._executors]); IEnumerable CasePartitioner(object? input, int targetCount) { diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs index 351d6e8e6b..731f54da72 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; @@ -27,10 +26,8 @@ public sealed class AgentProxy : AIAgent /// The actor client used to communicate with the agent. public AgentProxy(string name, IActorClient client) { - Throw.IfNull(client); - Throw.IfNullOrEmpty(name); - this._client = client; - this.Name = name; + this._client = Throw.IfNull(client, nameof(client)); + this.Name = Throw.IfNullOrEmpty(name, nameof(name)); } /// @@ -53,9 +50,9 @@ public override async Task RunAsync( AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - Throw.IfNull(messages); - var agentThread = GetAgentThreadId(thread); - return await this.RunAsync(messages, agentThread, cancellationToken).ConfigureAwait(false); + Throw.IfNull(messages, nameof(messages)); + string agentThreadId = GetAgentThreadId(thread); + return await this.RunAsync(messages, agentThreadId, cancellationToken).ConfigureAwait(false); } /// @@ -65,9 +62,9 @@ public override async IAsyncEnumerable RunStreamingAsync AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - Throw.IfNull(messages); - var agentThread = GetAgentThreadId(thread); - await foreach (var item in this.RunStreamingAsync(messages, agentThread, cancellationToken).ConfigureAwait(false)) + Throw.IfNull(messages, nameof(messages)); + string agentThreadId = GetAgentThreadId(thread); + await foreach (var item in this.RunStreamingAsync(messages, agentThreadId, cancellationToken).ConfigureAwait(false)) { yield return item; } @@ -75,7 +72,6 @@ public override async IAsyncEnumerable RunStreamingAsync private async Task RunAsync(IReadOnlyCollection messages, string threadId, CancellationToken cancellationToken) { - Throw.IfNull(messages); var handle = await this.RunCoreAsync(messages, threadId, cancellationToken).ConfigureAwait(false); var response = await handle.GetResponseAsync(cancellationToken).ConfigureAwait(false); return response.Status switch @@ -93,7 +89,6 @@ private async IAsyncEnumerable RunStreamingAsync( string threadId, [EnumeratorCancellation] CancellationToken cancellationToken) { - Throw.IfNull(messages); var response = await this.RunCoreAsync(messages, threadId, cancellationToken).ConfigureAwait(false); var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)); await foreach (var update in response.WatchUpdatesAsync(cancellationToken).ConfigureAwait(false)) @@ -130,9 +125,7 @@ private static string GetAgentThreadId(AgentThread? thread) private async Task RunCoreAsync(IReadOnlyCollection messages, string threadId, CancellationToken cancellationToken) { - Debug.Assert(messages is not null); - Debug.Assert(threadId is not null); - var newMessages = new List(messages); + List newMessages = [.. messages]; var runRequest = new AgentRunRequest { @@ -140,7 +133,7 @@ private async Task RunCoreAsync(IReadOnlyCollectionGets the configuration settings for the Azure OpenAI integration. public static AzureOpenAIConfig AzureOpenAI => LoadSection(); + /// Gets the configuration settings for the AzureAI integration. + public static AzureAIConfig AzureAI => LoadSection(); + /// Represents the configuration settings required to interact with the OpenAI service. public class OpenAIConfig { @@ -72,11 +75,6 @@ private TestConfiguration(IConfigurationRoot configRoot) this._configRoot = configRoot; } - /// - /// Gets the configuration settings for the AzureAI integration. - /// - public static AzureAIConfig AzureAI => LoadSection(); - private static T LoadSection([CallerMemberName] string? caller = null) { if (s_instance is null) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs new file mode 100644 index 0000000000..24ee30deee --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests; + +public class DeclarativeWorkflowContextTests +{ + [Fact] + public void InitializeDefaultValues() + { + // Act + Mock mockProvider = new(MockBehavior.Strict); + DeclarativeWorkflowOptions context = new(mockProvider.Object); + + // Assert + Assert.Equal(mockProvider.Object, context.AgentProvider); + Assert.Null(context.MaximumCallDepth); + Assert.Null(context.MaximumExpressionLength); + Assert.Same(NullLoggerFactory.Instance, context.LoggerFactory); + } + + [Fact] + public void InitializeExplicitValues() + { + // Arrange + TokenCredential credentials = new DefaultAzureCredential(); + int maxCallDepth = 10; + int maxExpressionLength = 100; + ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { }); + + // Act + Mock mockProvider = new(MockBehavior.Strict); + DeclarativeWorkflowOptions context = new(mockProvider.Object) + { + MaximumCallDepth = maxCallDepth, + MaximumExpressionLength = maxExpressionLength, + LoggerFactory = loggerFactory + }; + + // Assert + Assert.Equal(mockProvider.Object, context.AgentProvider); + Assert.Equal(maxCallDepth, context.MaximumCallDepth); + Assert.Equal(maxExpressionLength, context.MaximumExpressionLength); + Assert.Same(loggerFactory, context.LoggerFactory); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs new file mode 100644 index 0000000000..cd9f0c28d8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests; + +/// +/// Tests declarative workflow exceptions. +/// +public sealed class DeclarativeWorkflowExceptionTest(ITestOutputHelper output) : WorkflowTest(output) +{ + [Fact] + public void WorkflowExecutionException() + { + AssertDefault(() => throw new DeclarativeActionException()); + AssertMessage((message) => throw new DeclarativeActionException(message)); + AssertInner((message, inner) => throw new DeclarativeActionException(message, inner)); + } + + [Fact] + public void WorkflowModelException() + { + AssertDefault(() => throw new DeclarativeModelException()); + AssertMessage((message) => throw new DeclarativeModelException(message)); + AssertInner((message, inner) => throw new DeclarativeModelException(message, inner)); + } + + private static void AssertDefault(Action throwAction) where TException : Exception + { + TException exception = Assert.Throws(() => throwAction.Invoke()); + Assert.NotEmpty(exception.Message); + Assert.Null(exception.InnerException); + } + + private static void AssertMessage(Action throwAction) where TException : Exception + { + const string message = "Test exception message"; + TException exception = Assert.Throws(() => throwAction.Invoke(message)); + Assert.Equal(message, exception.Message); + Assert.Null(exception.InnerException); + } + + private static void AssertInner(Action throwAction) where TException : Exception + { + const string message = "Test exception message"; + NotSupportedException innerException = new("Inner exception message"); + TException exception = Assert.Throws(() => throwAction.Invoke(message, innerException)); + Assert.Equal(message, exception.Message); + Assert.Equal(innerException, exception.InnerException); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs new file mode 100644 index 0000000000..701bdf04d1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Agents.Workflows.Reflection; +using Microsoft.Bot.ObjectModel; +using Moq; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests; + +/// +/// Tests execution of workflow created by . +/// +public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : WorkflowTest(output) +{ + private ImmutableList WorkflowEvents { get; set; } = ImmutableList.Empty; + + private ImmutableDictionary WorkflowEventCounts { get; set; } = ImmutableDictionary.Empty; + + [Theory] + [InlineData("BadEmpty.yaml")] + [InlineData("BadId.yaml")] + [InlineData("BadKind.yaml")] + public async Task InvalidWorkflow(string workflowFile) + { + await Assert.ThrowsAsync(() => this.RunWorkflow(workflowFile)); + this.AssertNotExecuted("end_all"); + } + + [Fact] + public async Task LoopEachAction() + { + await this.RunWorkflow("LoopEach.yaml"); + this.AssertExecutionCount(expectedCount: 35); + this.AssertExecuted("foreach_loop"); + this.AssertExecuted("end_all"); + } + + [Fact] + public async Task LoopBreakAction() + { + await this.RunWorkflow("LoopBreak.yaml"); + this.AssertExecutionCount(expectedCount: 7); + this.AssertExecuted("foreach_loop"); + this.AssertExecuted("breakLoop_now"); + this.AssertExecuted("end_all"); + this.AssertNotExecuted("setVariable_loop"); + this.AssertNotExecuted("sendActivity_loop"); + } + + [Fact] + public async Task LoopContinueAction() + { + await this.RunWorkflow("LoopContinue.yaml"); + this.AssertExecutionCount(expectedCount: 23); + this.AssertExecuted("foreach_loop"); + this.AssertExecuted("continueLoop_now"); + this.AssertExecuted("end_all"); + this.AssertNotExecuted("setVariable_loop"); + this.AssertNotExecuted("sendActivity_loop"); + } + + [Fact] + public async Task GotoAction() + { + await this.RunWorkflow("Goto.yaml"); + this.AssertExecutionCount(expectedCount: 2); + this.AssertExecuted("goto_end"); + this.AssertExecuted("end_all"); + this.AssertNotExecuted("sendActivity_1"); + this.AssertNotExecuted("sendActivity_2"); + this.AssertNotExecuted("sendActivity_3"); + } + + [Theory] + [InlineData(12)] + [InlineData(37)] + public async Task ConditionAction(int input) + { + await this.RunWorkflow("Condition.yaml", input); + this.AssertExecutionCount(expectedCount: 9); + this.AssertExecuted("setVariable_test"); + this.AssertExecuted("conditionGroup_test"); + if (input % 2 == 0) + { + this.AssertExecuted("conditionItem_even"); + this.AssertExecuted("sendActivity_even"); + this.AssertNotExecuted("conditionItem_odd"); + this.AssertNotExecuted("sendActivity_odd"); + this.AssertMessage("EVEN"); + } + else + { + this.AssertExecuted("conditionItem_odd"); + this.AssertExecuted("sendActivity_odd"); + this.AssertNotExecuted("conditionItem_even"); + this.AssertNotExecuted("sendActivity_even"); + this.AssertMessage("ODD"); + } + this.AssertExecuted("end_all"); + } + + [Theory] + [InlineData(12, 7)] + [InlineData(37, 9)] + public async Task ConditionActionWithElse(int input, int expectedActions) + { + await this.RunWorkflow("ConditionElse.yaml", input); + this.AssertExecutionCount(expectedActions); + this.AssertExecuted("setVariable_test"); + this.AssertExecuted("conditionGroup_test"); + if (input % 2 == 0) + { + this.AssertExecuted("sendActivity_else"); + this.AssertNotExecuted("conditionItem_odd"); + this.AssertNotExecuted("sendActivity_odd"); + } + else + { + this.AssertExecuted("conditionItem_odd"); + this.AssertExecuted("sendActivity_odd"); + this.AssertNotExecuted("sendActivity_else"); + } + this.AssertExecuted("end_all"); + } + + [Theory] + [InlineData("Single.yaml", 1, "end_all")] + [InlineData("EditTable.yaml", 2, "edit_var")] + [InlineData("EditTableV2.yaml", 2, "edit_var")] + [InlineData("ParseValue.yaml", 1, "parse_var")] + [InlineData("SetTextVariable.yaml", 1, "set_text")] + [InlineData("ClearAllVariables.yaml", 1, "clear_all")] + [InlineData("ResetVariable.yaml", 2, "clear_var")] + public async Task ExecuteAction(string workflowFile, int expectedCount, string expectedId) + { + await this.RunWorkflow(workflowFile); + this.AssertExecutionCount(expectedCount); + this.AssertExecuted(expectedId); + } + + [Theory] + [InlineData(typeof(ActivateExternalTrigger.Builder))] + [InlineData(typeof(AdaptiveCardPrompt.Builder))] + [InlineData(typeof(BeginDialog.Builder))] + [InlineData(typeof(CSATQuestion.Builder))] + [InlineData(typeof(CancelAllDialogs.Builder))] + [InlineData(typeof(CancelDialog.Builder))] + [InlineData(typeof(CreateSearchQuery.Builder))] + [InlineData(typeof(DeleteActivity.Builder))] + [InlineData(typeof(DisableTrigger.Builder))] + [InlineData(typeof(DisconnectedNodeContainer.Builder))] + [InlineData(typeof(EmitEvent.Builder))] + [InlineData(typeof(EndDialog.Builder))] + [InlineData(typeof(GetActivityMembers.Builder))] + [InlineData(typeof(GetConversationMembers.Builder))] + [InlineData(typeof(HttpRequestAction.Builder))] + [InlineData(typeof(InvokeAIBuilderModelAction.Builder))] + [InlineData(typeof(InvokeConnectorAction.Builder))] + [InlineData(typeof(InvokeCustomModelAction.Builder))] + [InlineData(typeof(InvokeFlowAction.Builder))] + [InlineData(typeof(InvokeSkillAction.Builder))] + [InlineData(typeof(LogCustomTelemetryEvent.Builder))] + [InlineData(typeof(OAuthInput.Builder))] + [InlineData(typeof(Question.Builder))] + [InlineData(typeof(RecognizeIntent.Builder))] + [InlineData(typeof(RepeatDialog.Builder))] + [InlineData(typeof(ReplaceDialog.Builder))] + [InlineData(typeof(SearchAndSummarizeContent.Builder))] + [InlineData(typeof(SearchAndSummarizeWithCustomModel.Builder))] + [InlineData(typeof(SearchKnowledgeSources.Builder))] + [InlineData(typeof(SignOutUser.Builder))] + [InlineData(typeof(TransferConversation.Builder))] + [InlineData(typeof(TransferConversationV2.Builder))] + [InlineData(typeof(UnknownDialogAction.Builder))] + [InlineData(typeof(UpdateActivity.Builder))] + [InlineData(typeof(WaitForConnectorTrigger.Builder))] + public void UnsupportedAction(Type type) + { + DialogAction.Builder? unsupportedAction = (DialogAction.Builder?)Activator.CreateInstance(type); + Assert.NotNull(unsupportedAction); + unsupportedAction.Id = "action_bad"; + AdaptiveDialog.Builder dialogBuilder = + new() + { + BeginDialog = + new OnActivity.Builder() + { + Id = "workflow", + Actions = [unsupportedAction] + } + }; + AdaptiveDialog dialog = dialogBuilder.Build(); + + WorkflowScopes scopes = new(); + Mock mockAgentProvider = new(MockBehavior.Strict); + DeclarativeWorkflowOptions options = new(mockAgentProvider.Object); + WorkflowActionVisitor visitor = new(new RootExecutor(), new DeclarativeWorkflowState(RecalcEngineFactory.Create()), options); + WorkflowElementWalker walker = new(dialog, visitor); + Assert.True(visitor.HasUnsupportedActions); + } + + private void AssertExecutionCount(int expectedCount) + { + Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorInvokeEvent)]); + Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorCompleteEvent)]); + } + + private void AssertNotExecuted(string executorId) + { + Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); + Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); + } + + private void AssertExecuted(string executorId) + { + Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); + Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); + } + + private void AssertMessage(string message) + { + Assert.Contains(this.WorkflowEvents.OfType(), e => string.Equals(e.Response.Messages[0].Text.Trim(), message, StringComparison.Ordinal)); + } + + private Task RunWorkflow(string workflowPath) => this.RunWorkflow(workflowPath, string.Empty); + + private async Task RunWorkflow(string workflowPath, TInput workflowInput) where TInput : notnull + { + using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); + Mock mockAgentProvider = new(MockBehavior.Strict); + DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output }; + + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); + + StreamingRun run = await InProcessExecution.StreamAsync(workflow, workflowInput); + + this.WorkflowEvents = run.WatchStreamAsync().ToEnumerable().ToImmutableList(); + foreach (WorkflowEvent workflowEvent in this.WorkflowEvents) + { + if (workflowEvent is ExecutorInvokeEvent invokeEvent) + { + DeclarativeExecutorResult? message = invokeEvent.Data as DeclarativeExecutorResult; + this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]"); + } + else if (workflowEvent is AgentRunResponseEvent messageEvent) + { + this.Output.WriteLine($"MESSAGE: {messageEvent.Response.Messages[0].Text.Trim()}"); + } + } + this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToImmutableDictionary(e => e.Key, e => e.Count()); + } + + private sealed class RootExecutor() : + ReflectingExecutor(WorkflowActionVisitor.RootId("workflow")), + IMessageHandler + { + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs new file mode 100644 index 0000000000..bf450b41a2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Extensions; + +public class FormulaValueExtensionsTests +{ + [Fact] + public void BooleanValue() + { + BooleanValue formulaValue = FormulaValue.New(true); + DataValue dataValue = formulaValue.ToDataValue(); + BooleanDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.Value, typedValue.Value); + + BooleanValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(typedValue.Value, formulaCopy.Value); + + Assert.Equal(bool.TrueString, formulaValue.Format()); + } + + [Fact] + public void StringValues() + { + StringValue formulaValue = FormulaValue.New("test value"); + Assert.Equal(StringDataType.Instance, formulaValue.GetDataType()); + + DataValue dataValue = formulaValue.ToDataValue(); + StringDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.Value, typedValue.Value); + + StringValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + Assert.Equal(typedValue.Value, formulaCopy.Value); + + Assert.Equal(formulaValue.Value, formulaValue.Format()); + } + + [Fact] + public void DecimalValues() + { + DecimalValue formulaValue = FormulaValue.New(45.3m); + Assert.Equal(NumberDataType.Instance, formulaValue.GetDataType()); + + DataValue dataValue = formulaValue.ToDataValue(); + NumberDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.Value, typedValue.Value); + + DecimalValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + Assert.Equal(typedValue.Value, formulaCopy.Value); + + Assert.Equal("45.3", formulaValue.Format()); + } + + [Fact] + public void NumberValues() + { + NumberValue formulaValue = FormulaValue.New(3.1415926535897); + Assert.Equal(FloatDataType.Instance, formulaValue.GetDataType()); + + DataValue dataValue = formulaValue.ToDataValue(); + FloatDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.Value, typedValue.Value); + + NumberValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + Assert.Equal(typedValue.Value, formulaCopy.Value); + + Assert.Equal("3.1415926535897", formulaValue.Format()); + } + + [Fact] + public void BlankValues() + { + BlankValue formulaValue = FormulaValue.NewBlank(); + Assert.Equal(DataType.Blank, formulaValue.GetDataType()); + + BlankDataValue dataCopy = Assert.IsType(formulaValue.ToDataValue()); + + Assert.Equal(string.Empty, formulaValue.Format()); + } + + [Fact] + public void VoidValues() + { + VoidValue formulaValue = FormulaValue.NewVoid(); + Assert.Equal(DataType.Unspecified, formulaValue.GetDataType()); + BlankDataValue dataCopy = Assert.IsType(formulaValue.ToDataValue()); + } + + [Fact] + public void DateValues() + { + DateTime timestamp = DateTime.UtcNow.Date; + DateValue formulaValue = FormulaValue.NewDateOnly(timestamp); + Assert.Equal(DataType.Date, formulaValue.GetDataType()); + + DataValue dataValue = formulaValue.ToDataValue(); + DateDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), typedValue.Value); + + DateValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(typedValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); + + Assert.Equal($"{timestamp}", formulaValue.Format()); + } + + [Fact] + public void DateTimeValues() + { + DateTime timestamp = DateTime.UtcNow; + DateTimeValue formulaValue = FormulaValue.New(timestamp); + Assert.Equal(DataType.DateTime, formulaValue.GetDataType()); + + DataValue dataValue = formulaValue.ToDataValue(); + DateTimeDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), typedValue.Value); + + DateTimeValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + Assert.Equal(typedValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); + + Assert.Equal($"{timestamp}", formulaValue.Format()); + } + + [Fact] + public void TimeValues() + { + TimeValue formulaValue = FormulaValue.New(TimeSpan.Parse("10:35")); + Assert.Equal(DataType.Time, formulaValue.GetDataType()); + + DataValue dataValue = formulaValue.ToDataValue(); + TimeDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.Value, typedValue.Value); + + TimeValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + Assert.Equal(typedValue.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"))); + Assert.Equal(DataType.EmptyRecord, formulaValue.GetDataType()); + + RecordDataValue dataValue = formulaValue.ToRecord(); + 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( + """ + { + "FieldA": "Value1", + "FieldB": "Value2", + "FieldC": "Value3" + } + """, + formulaValue.Format().Replace(Environment.NewLine, "\n")); + } + + [Fact] + public void TableValues() + { + RecordValue recordValue = FormulaValue.NewRecordFromFields( + new NamedValue("FieldA", FormulaValue.New("Value1")), + new NamedValue("FieldB", FormulaValue.New("Value2")), + new NamedValue("FieldC", FormulaValue.New("Value3"))); + TableValue formulaValue = TableValue.NewTable(recordValue.Type, [recordValue]); + + TableDataValue dataValue = formulaValue.ToTable(); + Assert.Equal(formulaValue.Rows.Count(), dataValue.Values.Length); + + TableValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue(), exactMatch: false); + Assert.Equal(formulaCopy.Rows.Count(), dataValue.Values.Length); + + Assert.Equal( + """ + [ + { + "FieldA": "Value1", + "FieldB": "Value2", + "FieldC": "Value3" + } + ] + """, + formulaValue.Format().Replace(Environment.NewLine, "\n")); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/StringExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/StringExtensionsTests.cs new file mode 100644 index 0000000000..87a5ce6848 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/StringExtensionsTests.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Workflows.Declarative.Extensions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.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/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs new file mode 100644 index 0000000000..75e8056e70 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Reflection; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Interpreter; + +/// +/// Tests execution of workflow created by . +/// +public sealed class DeclarativeWorkflowModelTest(ITestOutputHelper output) : WorkflowTest(output) +{ + [Fact] + public async Task GetDepthForDefault() + { + DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); + Assert.Equal(0, model.GetDepth(null)); + } + + [Fact] + public async Task GetDepthForMissingNode() + { + DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); + Assert.Throws(() => model.GetDepth("missing")); + } + + [Fact] + public async Task ConnectMissingNode() + { + TestExecutor rootExecutor = this.CreateExecutor("root"); + DeclarativeWorkflowModel model = new(rootExecutor); + model.AddLink("root", "missing"); + WorkflowBuilder workflowBuilder = new(rootExecutor); + Assert.Throws(() => model.ConnectNodes(workflowBuilder)); + } + + [Fact] + public async Task AddToMissingParent() + { + DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); + Assert.Throws(() => model.AddNode(this.CreateExecutor("next"), "missing")); + } + + [Fact] + public async Task LinkFromMissingSource() + { + DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); + Assert.Throws(() => model.AddLink("missing", "anything")); + } + + [Fact] + public async Task LocateMissingParent() + { + DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); + Assert.Null(model.LocateParent(null)); + Assert.Throws(() => model.LocateParent("missing")); + } + + private TestExecutor CreateExecutor(string id) => new(id); + + internal sealed class TestExecutor(string actionId) : + ReflectingExecutor(actionId), + IMessageHandler + { + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj new file mode 100644 index 0000000000..b13d3cba9f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + $(ProjectsTargetFrameworks) + $(NoWarn);IDE1006 + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs new file mode 100644 index 0000000000..af3a31c735 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class ClearAllVariablesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(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 + ClearAllVariablesExecutor action = new(model, this.GetState()); + await this.Execute(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 + ClearAllVariablesExecutor action = new(model, this.GetState()); + await this.Execute(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/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs new file mode 100644 index 0000000000..35f8494d93 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class ParseValueExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task ParseTable() + { + // Arrange + RecordDataType.Builder recordBuilder = + new() + { + Properties = + { + {"key1", new PropertyInfo.Builder() { Type = DataType.String } }, + } + }; + ParseValue model = + this.CreateModel( + this.FormatDisplayName(nameof(ParseTable)), + recordBuilder, + @"{ ""key1"": ""val1"" }"); + + // Act + ParseValueExecutor action = new(model, this.GetState()); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState("Target", FormulaValue.NewRecordFromFields(new NamedValue("key1", FormulaValue.New("val1")))); + } + + [Fact] + public async Task ParseBoolean() + { + // Arrange + ParseValue model = + this.CreateModel( + this.FormatDisplayName(nameof(ParseTable)), + new BooleanDataType.Builder(), + "True"); + + // Act + ParseValueExecutor action = new(model, this.GetState()); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState("Target", FormulaValue.New(true)); + } + + [Fact] + public async Task ParseNumber() + { + // Arrange + ParseValue model = + this.CreateModel( + this.FormatDisplayName(nameof(ParseNumber)), + new NumberDataType.Builder(), + "42"); + + // Act + ParseValueExecutor action = new(model, this.GetState()); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState("Target", FormulaValue.New(42)); + } + + [Fact] + public async Task ParseString() + { + // Arrange + ParseValue model = + this.CreateModel( + this.FormatDisplayName(nameof(ParseString)), + new StringDataType.Builder(), + "Hello, World!"); + + // Act + ParseValueExecutor action = new(model, this.GetState()); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState("Target", FormulaValue.New("Hello, World!")); + } + + private ParseValue CreateModel(string displayName, DataType.Builder typeBuilder, string sourceText) + { + ParseValue.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + ValueType = typeBuilder, + Variable = PropertyPath.TopicVariable("Target"), + Value = new ValueExpression.Builder(ValueExpression.Literal(StringDataValue.Create(sourceText))), + }; + + ParseValue model = this.AssignParent(actionBuilder); + + return model; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs new file mode 100644 index 0000000000..d705d4ebb8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class ResetVariableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task ResetDefinedValue() + { + // Arrange + this.Scopes.Set("MyVar1", FormulaValue.New("Value #1")); + this.Scopes.Set("MyVar2", FormulaValue.New("Value #2")); + + ResetVariable model = + this.CreateModel( + this.FormatDisplayName(nameof(ResetDefinedValue)), + FormatVariablePath("MyVar1")); + + // Act + ResetVariableExecutor action = new(model, this.GetState()); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyUndefined("MyVar1"); + this.VerifyState("MyVar2", FormulaValue.New("Value #2")); + } + + [Fact] + public async Task ResetUndefinedValue() + { + // Arrange + this.Scopes.Set("MyVar1", FormulaValue.New("Value #1")); + + ResetVariable model = + this.CreateModel( + this.FormatDisplayName(nameof(ResetUndefinedValue)), + FormatVariablePath("NoVar")); + + // Act + ResetVariableExecutor action = new(model, this.GetState()); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyUndefined("NoVar"); + this.VerifyState("MyVar1", FormulaValue.New("Value #1")); + } + + 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/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs new file mode 100644 index 0000000000..4337ef2952 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; +using Microsoft.Bot.ObjectModel; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class SendActivityExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task CaptureActivity() + { + // Arrange + SendActivity model = + this.CreateModel( + this.FormatDisplayName(nameof(CaptureActivity)), + "Test activity message"); + + // Act + SendActivityExecutor action = new(model, this.GetState()); + WorkflowEvent[] events = await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + Assert.Contains(events, e => e is AgentRunResponseEvent); + } + + 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/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs new file mode 100644 index 0000000000..6ca809d1dd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class SetTextVariableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task SetLiteralValue() + { + // Arrange + SetTextVariable model = + this.CreateModel( + this.FormatDisplayName(nameof(SetLiteralValue)), + FormatVariablePath("TextVar"), + "Text variable value"); + + // Act + SetTextVariableExecutor action = new(model, this.GetState()); + await this.Execute(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 + SetTextVariableExecutor action = new(model, this.GetState()); + await this.Execute(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/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs new file mode 100644 index 0000000000..a6ff29a597 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class SetVariableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public void InvalidModel() + { + // Arrange, Act, Assert + Assert.Throws(() => new SetVariableExecutor(new SetVariable(), this.GetState())); + } + + [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 + SetVariableExecutor action = new(model, this.GetState()); + await this.Execute(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/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs new file mode 100644 index 0000000000..13ce04e1b9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Agents.Workflows.Reflection; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Base test class for implementations. +/// +public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : WorkflowTest(output) +{ + internal WorkflowScopes Scopes { get; } = new(); + + internal DeclarativeWorkflowState GetState() => new(RecalcEngineFactory.Create(), this.Scopes); + + protected ActionId CreateActionId() => new($"{this.GetType().Name}_{Guid.NewGuid():N}"); + + protected string FormatDisplayName(string name) => $"{this.GetType().Name}_{name}"; + + internal async Task Execute(WorkflowActionExecutor executor) + { + TestWorkflowExecutor workflowExecutor = new(); + WorkflowBuilder workflowBuilder = new(workflowExecutor); + workflowBuilder.AddEdge(workflowExecutor, executor); + StreamingRun run = await InProcessExecution.StreamAsync(workflowBuilder.Build(), this.Scopes); + WorkflowEvent[] events = await run.WatchStreamAsync().ToArrayAsync(); + return events; + } + + internal void VerifyModel(DialogAction model, WorkflowActionExecutor action) + { + Assert.Equal(model.Id, action.Id); + Assert.Equal(model, action.Model); + } + + protected void VerifyState(string variableName, FormulaValue expectedValue) => this.VerifyState(variableName, VariableScopeNames.Topic, expectedValue); + + internal void VerifyState(string variableName, string scopeName, FormulaValue expectedValue) + { + FormulaValue actualValue = this.Scopes.Get(variableName, scopeName); + Assert.Equal(expectedValue.Format(), actualValue.Format()); + } + + protected void VerifyUndefined(string variableName) => this.VerifyUndefined(variableName, VariableScopeNames.Topic); + + internal void VerifyUndefined(string variableName, string scopeName) + { + Assert.IsType(this.Scopes.Get(variableName, scopeName)); + } + + 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]; + } + + internal sealed class TestWorkflowExecutor() : + ReflectingExecutor(nameof(TestWorkflowExecutor)), + IMessageHandler + { + public async ValueTask HandleAsync(WorkflowScopes message, IWorkflowContext context) + { + await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id)).ConfigureAwait(false); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs new file mode 100644 index 0000000000..690a20614c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; + +public class RecalcEngineFactoryTests(ITestOutputHelper output) : RecalcEngineTest(output) +{ + [Fact] + public void VariableUpdateTest() + { + RecalcEngine engine = this.CreateEngine(); + + FormulaValue evalResult; + + engine.UpdateVariable("single", FormulaValue.New(1)); + evalResult = engine.Eval("single"); + Console.WriteLine($"# {evalResult.Format()}"); + + RecordValue recordSub = + FormulaValue.NewRecordFromFields( + new NamedValue("sub", FormulaValue.New(3.14))); + RecordValue recordRoot = + FormulaValue.NewRecordFromFields( + new NamedValue("another", FormulaValue.NewBlank()), + new NamedValue("val", FormulaValue.New(2.82)), + new NamedValue("root", recordSub)); + engine.DeleteFormula("Topic"); + engine.UpdateVariable("Topic", recordRoot); + evalResult = engine.Eval("Topic"); + Console.WriteLine($"# {evalResult.Format()}"); + evalResult = engine.Eval("Topic.val"); + Console.WriteLine($"# {evalResult.Format()}"); + evalResult = engine.Eval("Topic.root"); + Console.WriteLine($"# {evalResult.Format()}"); + evalResult = engine.Eval("Topic.root.sub"); + Console.WriteLine($"# {evalResult.Format()}"); + //recordRoot.UpdateField("another", FormulaValue.New("abc")); + RecordValue recordRoot2 = + FormulaValue.NewRecordFromFields( + new NamedValue("another", FormulaValue.New("abc")), + new NamedValue("val", FormulaValue.New(2.82)), + new NamedValue("root", recordSub)); + engine.DeleteFormula("Topic"); + engine.UpdateVariable("Topic", recordRoot2); + evalResult = engine.Eval("Topic.another"); + Console.WriteLine($"# {evalResult.Format()}"); + engine.UpdateVariable("Topic.another", FormulaValue.New(-1)); + evalResult = engine.Eval("Topic.another"); + Console.WriteLine($"# {evalResult.Format()}"); + } + + [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 = RecalcEngineFactory.Create(2000, 3); + + // Assert + Assert.Equal(2000, engine.Config.MaximumExpressionLength); + Assert.Equal(3, engine.Config.MaxCallDepth); + + // 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/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs new file mode 100644 index 0000000000..db849323aa --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.PowerFx; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; + +/// +/// Base test class for PowerFx engine tests. +/// +public abstract class RecalcEngineTest(ITestOutputHelper output) : WorkflowTest(output) +{ + internal WorkflowScopes Scopes { get; } = new(); + + protected RecalcEngine CreateEngine(int maximumExpressionLength = 500) => RecalcEngineFactory.Create(maximumExpressionLength); +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs new file mode 100644 index 0000000000..cd2f6c05ea --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.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(); + this.Scopes.Bind(engine); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal("Hello World", result); + } + + [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/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs new file mode 100644 index 0000000000..53cd9983f5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs @@ -0,0 +1,705 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Immutable; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.Bot.ObjectModel.Exceptions; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; + +public class WorkflowExpressionEngineTests : RecalcEngineTest +{ + private static class Variables + { + public const string GlobalValue = nameof(GlobalValue); + public const string BoolValue = nameof(BoolValue); + public const string StringValue = nameof(StringValue); + public const string IntValue = nameof(IntValue); + public const string NumberValue = nameof(NumberValue); + public const string EnumValue = nameof(EnumValue); + public const string ObjectValue = nameof(ObjectValue); + public const string ArrayValue = nameof(ArrayValue); + public const string BlankValue = nameof(BlankValue); + } + + public static readonly RecordValue ObjectData = FormulaValue.NewRecordFromFields(new NamedValue(nameof(EnvironmentVariableReference.SchemaName), FormulaValue.New("test"))); + public static readonly TableValue TableData = FormulaValue.NewSingleColumnTable(FormulaValue.New("a"), FormulaValue.New("b")); + + public WorkflowExpressionEngineTests(ITestOutputHelper output) + : base(output) + { + this.Scopes.Set(Variables.GlobalValue, FormulaValue.New(255), VariableScopeNames.Global); + this.Scopes.Set(Variables.BoolValue, FormulaValue.New(true), VariableScopeNames.Topic); + this.Scopes.Set(Variables.StringValue, FormulaValue.New("Hello World"), VariableScopeNames.Topic); + this.Scopes.Set(Variables.IntValue, FormulaValue.New(long.MaxValue), VariableScopeNames.Topic); + this.Scopes.Set(Variables.NumberValue, FormulaValue.New(33.3), VariableScopeNames.Topic); + this.Scopes.Set(Variables.EnumValue, FormulaValue.New(nameof(VariablesToClear.ConversationScopedVariables)), VariableScopeNames.Topic); + this.Scopes.Set(Variables.ObjectValue, ObjectData, VariableScopeNames.Topic); + this.Scopes.Set(Variables.ArrayValue, TableData, VariableScopeNames.Topic); + this.Scopes.Set(Variables.BlankValue, FormulaValue.NewBlank(), VariableScopeNames.Topic); + } + + #region Unsupported Expression Tests + + [Fact] + public void AdaptiveCardExpressionGetValueUnsupported() + { + this.EvaluateUnsupportedExpression(expressionEngine => expressionEngine.GetValue(AdaptiveCardExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)), this.Scopes.BuildState())); + } + + [Fact] + public void DialogExpressionGetValueUnsupported() + { + this.EvaluateUnsupportedExpression(expressionEngine => expressionEngine.GetValue(DialogExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)), this.Scopes.BuildState())); + } + + [Fact] + public void FileExpressionGetValueUnsupported() + { + this.EvaluateUnsupportedExpression(expressionEngine => expressionEngine.GetValue(FileExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)), this.Scopes.BuildState())); + } + + #endregion + + #region BoolExpression Tests + + [Fact] + public void BoolExpressionGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((BoolExpression)null!); + } + + [Fact] + public void BoolExpressionGetValueForInvalid() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(BoolExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue))); + } + + [Fact] + public void BoolExpressionGetValueForLiteral() + { + // Arrange, Act & Assert + this.EvaluateExpression( + BoolExpression.Literal(true), + expectedValue: true); + } + + [Fact] + public void BoolExpressionGetValueForBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + BoolExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: false); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void BoolExpressionGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + BoolExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue)), + expectedValue: true, + useState); + } + + [Fact] + public void BoolExpressionGetValueForFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + BoolExpression.Expression("true || false"), + expectedValue: true); + } + + #endregion + + #region StringExpression Tests + + [Fact] + public void StringExpressionGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((StringExpression)null!); + } + + [Fact] + public void StringExpressionGetValueForInvalid() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(StringExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue))); + } + + [Fact] + public void StringExpressionGetValueForStringExpressionBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + StringExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: string.Empty); + } + + [Fact] + public void StringExpressionGetValueForLiteral() + { + // Arrange, Act & Assert + this.EvaluateExpression( + StringExpression.Literal("test"), + expectedValue: "test"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void StringExpressionGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + StringExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)), + expectedValue: "Hello World", + useState); + } + + [Fact] + public void StringExpressionGetValueForFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + StringExpression.Expression(@"""A"" & ""B"""), + expectedValue: "AB"); + } + + [Fact] + public void StringExpressionGetValueForRecord() + { + // Arrange + RecordValue state = FormulaValue.NewRecordFromFields([new NamedValue("test", FormulaValue.New("value"))]); + this.Scopes.Set("TestRecord", state, VariableScopeNames.Global); + + // Arrange, Act & Assert + this.EvaluateExpression( + StringExpression.Variable(PropertyPath.Create("Global.TestRecord")), + expectedValue: + """ + { + "test": "value" + } + """.Replace("\n", Environment.NewLine)); + } + + #endregion + + #region IntExpression Tests + + [Fact] + public void IntExpressionGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((IntExpression)null!); + } + + [Fact] + public void IntExpressionGetValueForInvalid() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(IntExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue))); + } + + [Fact] + public void IntExpressionGetValueForIntExpressionBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + IntExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: 0); + } + + [Fact] + public void IntExpressionGetValueForLiteral() + { + // Arrange, Act & Assert + this.EvaluateExpression( + IntExpression.Literal(7), + expectedValue: 7); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void IntExpressionGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + IntExpression.Variable(PropertyPath.TopicVariable(Variables.IntValue)), + expectedValue: long.MaxValue, + useState); + } + + [Fact] + public void IntExpressionGetValueForFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + IntExpression.Expression("1 + 6"), + expectedValue: 7); + } + + #endregion + + #region NumberExpression Tests + + [Fact] + public void NumberExpressionGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((NumberExpression)null!); + } + + [Fact] + public void NumberExpressionGetValueForInvalid() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(NumberExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue))); + } + + [Fact] + public void NumberExpressionGetValueForBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + NumberExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: 0); + } + + [Fact] + public void NumberExpressionGetValueForLiteral() + { + // Arrange, Act & Assert + this.EvaluateExpression( + NumberExpression.Literal(3.14), + expectedValue: 3.14); + } + + [Fact] + public void NumberExpressionGetValueForVariable() + { + // Arrange, Act & Assert + this.EvaluateExpression( + NumberExpression.Variable(PropertyPath.TopicVariable(Variables.NumberValue)), + expectedValue: 33.3); + } + + [Fact] + public void NumberExpressionGetValueForFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + NumberExpression.Expression("31.1 + 2.2"), + expectedValue: 33.3); + } + + #endregion + + #region DataValueExpression Tests + + [Fact] + public void DataValueExpressionGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((ValueExpression)null!); + } + + [Fact] + public void DataValueExpressionGetValueForDataValueExpressionBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ValueExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: DataValue.Blank()); + } + + [Fact] + public void DataValueExpressionGetValueForLiteral() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ValueExpression.Literal(DataValue.Create("test")), + expectedValue: DataValue.Create("test")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DataValueExpressionGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + ValueExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)), + expectedValue: DataValue.Create("Hello World"), + useState); + } + + [Fact] + public void DataValueExpressionGetValueForFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ValueExpression.Expression(@"""A"" & ""B"""), + expectedValue: DataValue.Create("AB")); + } + + #endregion + + #region EnumExpression Tests + + [Fact] + public void EnumExpressionGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((EnumExpression)null!); + } + + [Fact] + public void EnumExpressionGetValueForInvalid() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(EnumExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue))); + } + + [Fact] + public void EnumExpressionGetValueForLiteral() + { + // Arrange, Act & Assert + this.EvaluateExpression( + EnumExpression.Literal(VariablesToClearWrapper.Get(VariablesToClear.ConversationScopedVariables)), + expectedValue: VariablesToClear.ConversationScopedVariables); + } + + [Fact] + public void EnumExpressionGetValueForBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + EnumExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: VariablesToClear.ConversationScopedVariables); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void EnumExpressionGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + EnumExpression.Variable(PropertyPath.TopicVariable(Variables.EnumValue)), + expectedValue: VariablesToClear.ConversationScopedVariables, + useState); + } + + [Fact] + public void EnumExpressionGetValueForFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + EnumExpression.Expression(@"""ConversationScoped"" & ""Variables"""), + expectedValue: VariablesToClear.ConversationScopedVariables); + } + + #endregion + + #region ObjectExpression Tests + + [Fact] + public void ObjectExpressionGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((ObjectExpression)null!); + } + + [Fact] + public void ObjectExpressionGetValueForInvalid() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(ObjectExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue))); + } + + [Fact] + public void ObjectExpressionGetValueForLiteral() + { + // Arrange, Act & Assert + RecordDataValue.Builder recordBuilder = new(); + recordBuilder.Properties.Add(nameof(EnvironmentVariableReference.SchemaName), new StringDataValue("test")); + RecordDataValue objectRecord = recordBuilder.Build(); + EnvironmentVariableReference element = new EnvironmentVariableReference.Builder() { SchemaName = "test" }.Build(); + this.EvaluateExpression( + ObjectExpression.Literal(objectRecord), + expectedValue: objectRecord); + } + + [Fact] + public void ObjectExpressionGetValueForBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ObjectExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: null); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ObjectExpressionGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + ObjectExpression.Variable(PropertyPath.TopicVariable(Variables.ObjectValue)), + expectedValue: ObjectData.ToRecord(), + useState); + } + + #endregion + + #region ArrayExpression Tests + + [Fact] + public void ArrayExpressionGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((ArrayExpression)null!); + } + + [Fact] + public void ArrayExpressionGetValueForInvalid() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(ArrayExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue))); + } + + [Fact] + public void ArrayExpressionGetValueForLiteral() + { + // Arrange, Act & Assert + string[] input = ["a", "b"]; + this.EvaluateExpression( + ArrayExpression.Literal(input.ToImmutableArray()), + expectedValue: input); + } + + [Fact] + public void ArrayExpressionGetValueForBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ArrayExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: []); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ArrayExpressionGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + ArrayExpression.Variable(PropertyPath.TopicVariable(Variables.ArrayValue)), + expectedValue: ["a", "b"], + useState); + } + + [Fact] + public void ArrayExpressionGetValueForFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ArrayExpression.Expression(@"[""a"", ""b""]"), + expectedValue: ["a", "b"]); + } + + #endregion + + #region ArrayExpressionOnly Tests + + [Fact] + public void ArrayExpressionOnlyGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((ArrayExpressionOnly)null!); + } + + [Fact] + public void ArrayExpressionOnlyGetValueForInvalid() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(ArrayExpressionOnly.Variable(PropertyPath.TopicVariable(Variables.BoolValue))); + } + + [Fact] + public void ArrayExpressionOnlyGetValueForBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ArrayExpressionOnly.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: []); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ArrayExpressionOnlyGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + ArrayExpressionOnly.Variable(PropertyPath.TopicVariable(Variables.ArrayValue)), + expectedValue: ["a", "b"], + useState); + } + + [Fact] + public void ArrayExpressionOnlyGetValueForFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ArrayExpressionOnly.Expression(@"[""a"", ""b""]"), + expectedValue: ["a", "b"]); + } + + #endregion + + private EvaluationResult EvaluateExpression(BoolExpression expression, bool expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(BoolExpression expression) + where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private EvaluationResult EvaluateExpression(StringExpression expression, string expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(StringExpression expression) + where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private EvaluationResult EvaluateExpression(IntExpression expression, long expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(IntExpression expression) + where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private EvaluationResult EvaluateExpression(NumberExpression expression, double expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(NumberExpression expression) + where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private EvaluationResult EvaluateExpression(ValueExpression expression, DataValue expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(ValueExpression expression) + where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private EvaluationResult EvaluateExpression(EnumExpression expression, TEnum expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + where TEnum : EnumWrapper + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(EnumExpression expression) + where TException : Exception + where TEnum : EnumWrapper + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private EvaluationResult EvaluateExpression(ObjectExpression expression, TValue? expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + where TValue : BotElement + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(ObjectExpression expression) + where TException : Exception + where TValue : BotElement + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private ImmutableArray EvaluateExpression(ArrayExpression expression, TValue[] expectedValue, bool useState = false) + => this.EvaluateArrayExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue); + + private void EvaluateInvalidExpression(ArrayExpression expression) + where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private ImmutableArray EvaluateExpression(ArrayExpressionOnly expression, TValue[] expectedValue, bool useState = false) + => this.EvaluateArrayExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue); + + private void EvaluateInvalidExpression(ArrayExpressionOnly expression) + where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private EvaluationResult EvaluateExpression( + Func> evaluator, + TValue? expectedValue, + SensitivityLevel expectedSensitivity = SensitivityLevel.None) + { + // Arrange + RecalcEngine engine = this.CreateEngine(); + this.Scopes.Bind(engine); + WorkflowExpressionEngine expressionEngine = new(engine); + + // Act + EvaluationResult result = evaluator.Invoke(expressionEngine); + + // Assert + Assert.Equal(expectedValue, result.Value); + Assert.Equal(expectedSensitivity, result.Sensitivity); + + return result; + } + + private ImmutableArray EvaluateArrayExpression( + Func> evaluator, + TValue[] expectedValue) + { + // Arrange + RecalcEngine engine = this.CreateEngine(); + this.Scopes.Bind(engine); + WorkflowExpressionEngine expressionEngine = new(engine); + + // Act + ImmutableArray result = evaluator.Invoke(expressionEngine); + + // Assert + Assert.Equal(expectedValue.Length, result.Length); + Assert.Equivalent(expectedValue, result); + + return result; + } + + private void EvaluateInvalidExpression(Action evaluator) where TException : Exception + { + // Arrange + RecalcEngine engine = this.CreateEngine(); + this.Scopes.Bind(engine); + WorkflowExpressionEngine expressionEngine = new(engine); + + // Act + Assert.Throws(() => evaluator.Invoke(expressionEngine)); + } + + private void EvaluateUnsupportedExpression(Action evaluator) + { + // Arrange + RecalcEngine engine = this.CreateEngine(); + this.Scopes.Bind(engine); + WorkflowExpressionEngine expressionEngine = new(engine); + + // Act + Assert.Throws(() => evaluator.Invoke(expressionEngine)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs new file mode 100644 index 0000000000..c09a448656 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; + +public class WorkflowScopesTests +{ + [Fact] + public void ConstructorInitializesAllScopes() + { + // Arrange & Act + WorkflowScopes scopes = new(); + + // Assert + RecordValue envRecord = scopes.BuildRecord(VariableScopeNames.Environment); + RecordValue topicRecord = scopes.BuildRecord(VariableScopeNames.Topic); + RecordValue globalRecord = scopes.BuildRecord(VariableScopeNames.Global); + RecordValue systemRecord = scopes.BuildRecord(VariableScopeNames.System); + + Assert.NotNull(envRecord); + Assert.NotNull(topicRecord); + Assert.NotNull(globalRecord); + Assert.NotNull(systemRecord); + } + + [Fact] + public void BuildRecordWhenEmpty() + { + // Arrange + WorkflowScopes scopes = new(); + + // Act + RecordValue record = scopes.BuildRecord(VariableScopeNames.Topic); + + // Assert + Assert.NotNull(record); + Assert.Empty(record.Fields); + } + + [Fact] + public void BuildRecordContainsSetValues() + { + // Arrange + WorkflowScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + scopes.Set("key1", testValue, VariableScopeNames.Topic); + + // Act + RecordValue record = scopes.BuildRecord(VariableScopeNames.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 + WorkflowScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + + // Act & Assert + scopes.Set("envKey", testValue, VariableScopeNames.Environment); + RecordValue envRecord = scopes.BuildRecord(VariableScopeNames.Environment); + Assert.Single(envRecord.Fields); + + scopes.Set("topicKey", testValue, VariableScopeNames.Topic); + RecordValue topicRecord = scopes.BuildRecord(VariableScopeNames.Topic); + Assert.Single(topicRecord.Fields); + + scopes.Set("globalKey", testValue, VariableScopeNames.Global); + RecordValue globalRecord = scopes.BuildRecord(VariableScopeNames.Global); + Assert.Single(globalRecord.Fields); + + scopes.Set("systemKey", testValue, VariableScopeNames.System); + RecordValue systemRecord = scopes.BuildRecord(VariableScopeNames.System); + Assert.Single(systemRecord.Fields); + } + + [Fact] + public void GetWithImplicitScope() + { + // Arrange + WorkflowScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + scopes.Set("key1", testValue, VariableScopeNames.Topic); + + // Act + FormulaValue result = scopes.Get("key1"); + + // Assert + Assert.Equal(testValue, result); + } + + [Fact] + public void GetWithSpecifiedScope() + { + // Arrange + WorkflowScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + scopes.Set("key1", testValue, VariableScopeNames.Global); + + // Act + FormulaValue result = scopes.Get("key1", VariableScopeNames.Global); + + // Assert + Assert.Equal(testValue, result); + } + + [Fact] + public void SetDefaultScope() + { + // Arrange + WorkflowScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + + // Act + scopes.Set("key1", testValue); + + // Assert + FormulaValue result = scopes.Get("key1", VariableScopeNames.Topic); + Assert.Equal(testValue, result); + } + + [Fact] + public void SetSpecifiedScope() + { + // Arrange + WorkflowScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + + // Act + scopes.Set("key1", testValue, VariableScopeNames.System); + + // Assert + FormulaValue result = scopes.Get("key1", VariableScopeNames.System); + Assert.Equal(testValue, result); + } + + [Fact] + public void SetOverwritesExistingValue() + { + // Arrange + WorkflowScopes scopes = new(); + FormulaValue initialValue = FormulaValue.New("initial"); + FormulaValue newValue = FormulaValue.New("new"); + + // Act + scopes.Set("key1", initialValue, VariableScopeNames.Topic); + scopes.Set("key1", newValue, VariableScopeNames.Topic); + + // Assert + FormulaValue result = scopes.Get("key1", VariableScopeNames.Topic); + Assert.Equal(newValue, result); + } + + [Fact] + public void RemoveSpecifiedScope() + { + // Arrange + WorkflowScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + + // Act + scopes.Set("key1", testValue); + + // Assert + FormulaValue result = scopes.Get("key1"); + Assert.Equal(testValue, result); + + // Act + scopes.Reset("key1"); + + // Assert + FormulaValue resultBlank = scopes.Get("key1"); + Assert.IsType(resultBlank); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/TestOutputAdapter.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/TestOutputAdapter.cs new file mode 100644 index 0000000000..25c8260cc5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.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.Agents.Workflows.Declarative.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/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs new file mode 100644 index 0000000000..7c0ad46d0e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Bot.ObjectModel; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests; + +/// +/// 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.Dispose(isDisposing: true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool isDisposing) + { + if (isDisposing) + { + this.Output.Dispose(); + } + } + + internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? VariableScopeNames.Topic}.{variableName}"; +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadEmpty.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadEmpty.yaml new file mode 100644 index 0000000000..5152afdf5f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadEmpty.yaml @@ -0,0 +1,4 @@ +# empty yaml +- id: 1 +- id: 2 +- id: 3 diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadId.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadId.yaml new file mode 100644 index 0000000000..1bc2063698 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadId.yaml @@ -0,0 +1,7 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + type: Message + actions: + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadKind.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadKind.yaml new file mode 100644 index 0000000000..006944db3c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadKind.yaml @@ -0,0 +1,8 @@ +kind: ToolDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ClearAllVariables.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ClearAllVariables.yaml new file mode 100644 index 0000000000..0af98b8991 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ClearAllVariables.yaml @@ -0,0 +1,10 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: ClearAllVariables + id: clear_all + variables: [ConversationScopedVariables] + diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml new file mode 100644 index 0000000000..5c29fe4a00 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml @@ -0,0 +1,32 @@ +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: my_workflow + type: Message + actions: + + - kind: SetVariable + id: setVariable_test + variable: Topic.TestValue + value: =Value(System.LastMessage.Text) + + - kind: ConditionGroup + id: conditionGroup_test + conditions: + - id: conditionItem_odd + condition: =Mod(Topic.TestValue, 2) = 1 + actions: + - kind: SendActivity + id: sendActivity_odd + activity: ODD + + - id: conditionItem_even + condition: =Mod(Topic.TestValue, 2) = 0 + actions: + - kind: SendActivity + id: sendActivity_even + activity: EVEN + + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ConditionElse.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ConditionElse.yaml new file mode 100644 index 0000000000..3f0073439d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ConditionElse.yaml @@ -0,0 +1,29 @@ +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: my_workflow + type: Message + actions: + + - kind: SetVariable + id: setVariable_test + variable: Topic.TestValue + value: =Value(System.LastMessage.Text) + + - kind: ConditionGroup + id: conditionGroup_test + conditions: + - id: conditionItem_odd + condition: =Mod(Topic.TestValue, 2) = 1 + actions: + - kind: SendActivity + id: sendActivity_odd + activity: ODD + elseActions: + - kind: SendActivity + id: sendActivity_else + activity: EVEN + + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTable.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTable.yaml new file mode 100644 index 0000000000..8f6910a7b5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTable.yaml @@ -0,0 +1,15 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetVariable + id: set_var + variable: Topic.MyTable + value: =[{id: 3}] + - kind: EditTable + id: edit_var + itemsVariable: Topic.MyTable + changeType: Add + value: ={id: 7} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTableV2.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTableV2.yaml new file mode 100644 index 0000000000..7ecda64328 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTableV2.yaml @@ -0,0 +1,16 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetVariable + id: set_var + variable: Topic.MyTable + value: =[{id: 3}] + - kind: EditTableV2 + id: edit_var + itemsVariable: Topic.MyTable + changeType: + kind: AddItemOperation + value: ={id: 7} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Goto.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Goto.yaml new file mode 100644 index 0000000000..1b300dbe1e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Goto.yaml @@ -0,0 +1,25 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + + - kind: GotoAction + id: goto_end + actionId: end_all + + - kind: SendActivity + id: sendActivity_1 + activity: NEVER 1! + + - kind: SendActivity + id: sendActivity_2 + activity: NEVER 2! + + - kind: SendActivity + id: sendActivity_3 + activity: NEVER 3! + + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopBreak.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopBreak.yaml new file mode 100644 index 0000000000..9d7d8db0f9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopBreak.yaml @@ -0,0 +1,29 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetVariable + id: setVariable_count + variable: Topic.Count + value: =0 + + - kind: Foreach + id: foreach_loop + items: =["a", "b", "c", "d", "e", "f"] + index: Topic.LoopIndex + value: Topic.LoopValue + actions: + - kind: BreakLoop + id: breakLoop_now + - kind: SetVariable + id: setVariable_loop + variable: Topic.Count + value: =Topic.Count + 1 + - kind: SendActivity + id: sendActivity_loop + activity: x{Topic.Count} - {Topic.LoopIndex}:{Topic.LoopValue} + + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopContinue.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopContinue.yaml new file mode 100644 index 0000000000..be39550cb8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopContinue.yaml @@ -0,0 +1,29 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetVariable + id: setVariable_count + variable: Topic.Count + value: =0 + + - kind: Foreach + id: foreach_loop + items: =["a", "b", "c", "d", "e", "f"] + index: Topic.LoopIndex + value: Topic.LoopValue + actions: + - kind: ContinueLoop + id: continueLoop_now + - kind: SetVariable + id: setVariable_loop + variable: Topic.Count + value: =Topic.Count + 1 + - kind: SendActivity + id: sendActivity_loop + activity: x{Topic.Count} - {Topic.LoopIndex}:{Topic.LoopValue} + + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopEach.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopEach.yaml new file mode 100644 index 0000000000..a18d9ee756 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopEach.yaml @@ -0,0 +1,27 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetVariable + id: setVariable_count + variable: Topic.Count + value: =0 + + - kind: Foreach + id: foreach_loop + items: =["a", "b", "c", "d", "e", "f"] + index: Topic.LoopIndex + value: Topic.LoopValue + actions: + - kind: SetVariable + id: setVariable_loop + variable: Topic.Count + value: =Topic.Count + 1 + - kind: SendActivity + id: sendActivity_loop + activity: x{Topic.Count} - {Topic.LoopIndex}:{Topic.LoopValue} + + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ParseValue.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ParseValue.yaml new file mode 100644 index 0000000000..e7270ce48c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ParseValue.yaml @@ -0,0 +1,11 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: ParseValue + id: parse_var + variable: Topic.MyVar + value: "42" + valueType: Number diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ResetVariable.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ResetVariable.yaml new file mode 100644 index 0000000000..b9fe367c83 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ResetVariable.yaml @@ -0,0 +1,14 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetVariable + id: set_var + variable: Topic.MyVar + value: 42 + - kind: ResetVariable + id: clear_var + variable: Topic.MyVar + diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.yaml new file mode 100644 index 0000000000..75d8b99239 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.yaml @@ -0,0 +1,10 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetTextVariable + id: set_text + variable: Topic.TestVar + value: "Test content" diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Single.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Single.yaml new file mode 100644 index 0000000000..54097d9e88 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Single.yaml @@ -0,0 +1,8 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: EndConversation + id: end_all diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml new file mode 100644 index 0000000000..d9482d5f6f --- /dev/null +++ b/workflows/DeepResearch.yaml @@ -0,0 +1,486 @@ +# +# This workflow demonstrates a multi-agent orchestrator that attempts to address complex user requests. +# +# For this workflow, several agents used, each with a prompt specific to their role: +# +# 1. Analyst Agent: Able to analyze the current task. +# Enable "Bing Grounding Tool" in the agent settings. +# See: ./setup/AnalystAgent.yaml +# +# 2. Manager Agent: Able to create plans and delegate tasks to other agents. +# See: ./setup/ManagerAgent.yaml +# +# 3. Research Agent: +# Enable "Bing Grounding" in the agent settings. +# See: ./setup/WebAgent.yaml +# +# With instructions: +# +# Only provide requested information in a way that is throughfully organized and formatted. +# Never include any analysis or code. +# Never generate a file. +# Avoid repeating yourself. +# +# 4. Coder Agent: +# Enable "Code Interpreter" in the agent settings. +# See: ./setup/CoderAgent.yaml +# +# 5. Weather Agent: Able to retrieve factual information from the web. +# Enable "Open API" in the agent settings using the wttr.json schema. +# See: ./setup/WeatherAgent.yaml +# +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + - kind: SetVariable + id: setVariable_aASlmF + displayName: List all available agents for this orchestrator + variable: Topic.AvailableAgents + value: |- + =[ + { + name: "WeatherAgent", + description: "Able to retrieve weather information", + agentid: Env.FOUNDRY_AGENT_RESEARCHWEATHER + }, + { + name: "CoderAgent", + description: "Able to write and execute Python code", + agentid: Env.FOUNDRY_AGENT_RESEARCHCODER + }, + { + name: "WebAgent", + description: "Able to perform generic websearches", + agentid: Env.FOUNDRY_AGENT_RESEARCHWEB + } + ] + + - kind: SetVariable + id: setVariable_V6yEbo + displayName: Get a summary of all the agents for use in prompts + variable: Topic.TeamDescription + value: "=Concat(ForAll(Topic.AvailableAgents, $\"- \" & name & $\": \" & description), Value, \"\n\")" + + - kind: SetVariable + id: setVariable_NZ2u0l + displayName: Set Task + variable: Topic.InputTask + value: =System.LastMessage.Text + + - kind: SetVariable + id: setVariable_10u2ZN + displayName: Set Task + variable: Topic.SeedTask + value: =Topic.InputTask + + - kind: SendActivity + id: sendActivity_yFsbRy + activity: Analyzing facts... + + - kind: AnswerQuestionWithAI + id: question_UDoMUw + displayName: Get Facts + autoSend: false + variable: Topic.TaskFacts + userInput: =Topic.InputTask + additionalInstructions: |- + {Env.FOUNDRY_AGENT_RESEARCHANALYST}, + In order to help begin addressing the user 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 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 must only use the 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: SendActivity + id: sendActivity_yFsbRz + activity: Creating a plan... + + - kind: AnswerQuestionWithAI + id: question_DsBaJU + displayName: Create a Plan + autoSend: false + variable: Topic.Plan + userInput: ="" + additionalInstructions: |- + {Env.FOUNDRY_AGENT_RESEARCHMANAGER}, + Your only job is to devise an efficient plan that identifies (by name) how a team member may contribute to addressing the user request. + + Only select the following team which is listed as "- [Name]: [Description]" + + {Topic.TeamDescription} + + The plan must be a bullet point list must be in the form "- [AgentName]: [Specific action or task for that agent to perform]" + + Remember, there is no requirement to involve the entire team -- only select team member's whose particular expertise is required for this task. + + - kind: SetVariable + id: setVariable_Kk2LDL + displayName: Define instructions + variable: Topic.TaskInstructions + value: |- + ="# TASK + Address the following user request: + + " & Topic.InputTask & " + + + # TEAM + Use the following team to answer this request: + + " & Topic.TeamDescription & " + + + # FACTS + Consider this initial fact sheet: + + " & Trim(Topic.TaskFacts.Text) & " + + + # PLAN + Here is the plan to follow as best as possible: + + " & Topic.Plan.Text + + - kind: SendActivity + id: sendActivity_bwNZiM + activity: {Topic.TaskInstructions} + + - kind: AnswerQuestionWithAI + id: question_o3BQkf + displayName: Progress Ledger Prompt + autoSend: false + variable: Topic.ProgressLedgerUpdate + userInput: =Topic.AgentResponseText + additionalInstructions: |- + {Env.FOUNDRY_AGENT_RESEARCHMANAGER}, + Recall we are working on the following request: + + {Topic.InputTask} + + 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 + }} + }} + + - kind: ParseValue + id: parse_rNZtlV + displayName: Parse ledger response + variable: Topic.TypedProgressLedger + value: =Topic.ProgressLedgerUpdate.Text + 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 + + - kind: ConditionGroup + id: conditionGroup_mVIecC + conditions: + - id: conditionItem_fj432c + condition: =Topic.TypedProgressLedger.is_request_satisfied.answer + displayName: If Done + actions: + + - kind: SendActivity + id: sendActivity_kdl3mC + activity: Completed! {Topic.TypedProgressLedger.is_request_satisfied.reason} + + - kind: AnswerQuestionWithAI + id: question_Ke3l1d + displayName: Generate Response + variable: Topic.FinalResponse + userInput: =Topic.SeedTask + additionalInstructions: |- + {Env.FOUNDRY_AGENT_RESEARCHMANAGER}, + We have completed the task. + Based only on the conversation and without adding any new information, synthesize the result of the conversation as a complete response to the user task. + The user will only every see this last response and not the entire conversation, so please ensure it is complete and self-contained. + + - kind: EndConversation + id: end_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_vBTQd3 + conditions: + + - id: conditionItem_fpaNL9 + condition: =.TypedProgressLedger.is_in_loop.answer + displayName: Is Loop + actions: + - kind: SendActivity + id: sendActivity_fpaNL9 + activity: {Topic.TypedProgressLedger.is_in_loop.reason} + + - id: conditionItem_NnqvXh + condition: =Not(Topic.TypedProgressLedger.is_progress_being_made.answer) + displayName: Is No Progress + actions: + - kind: SendActivity + id: sendActivity_NnqvXh + activity: {Topic.TypedProgressLedger.is_progress_being_made.reason} + + + - kind: ConditionGroup + id: conditionGroup_xzNrdM + conditions: + - id: conditionItem_NlQTBv + condition: =Topic.StallCount > 2 + displayName: Stall Count Exceeded + actions: + + - kind: SendActivity + id: sendActivity_H5lXdD + activity: Unable to make sufficient progress... + + - kind: ConditionGroup + id: conditionGroup_4s1Z27 + conditions: + - id: conditionItem_EXAlhZ + condition: =Topic.RestartCount > 2 + actions: + - kind: SendActivity + id: sendActivity_xKxFUU + activity: Stopping after attempting {Topic.RestartCount} restarts... + + - kind: EndConversation + id: end_GHVrFh + + - kind: SendActivity + id: sendActivity_cwNZiM + activity: Re-analyzing facts... + + - 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.InputTask + additionalInstructions: |- + {Env.FOUNDRY_AGENT_RESEARCHANALYST}, + 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} + + - kind: SendActivity + id: sendActivity_dsBaJU + activity: Re-analyzing plan... + + - kind: AnswerQuestionWithAI + id: question_uEJ456 + displayName: Create new Plan Prompt + autoSend: false + variable: Topic.Plan + userInput: ="" + additionalInstructions: |- + {Env.FOUNDRY_AGENT_RESEARCHMANAGER}, + 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} + + - kind: SetVariable + id: setVariable_jW7tmM + displayName: Set Plan as Context + variable: Topic.TaskInstructions + value: |- + ="# TASK + Address the following user request: + + " & Topic.InputTask & " + + + # TEAM + Use the following team to answer this request: + + " & Topic.TeamDescription & " + + + # FACTS + Consider this initial fact sheet: + + " & Topic.TaskFacts.Text & " + + + # PLAN + Here is the plan to follow as best as possible: + + " & Topic.Plan.Text + + - kind: SetVariable + id: setVariable_6J2snP + displayName: Reset Stall count + variable: Topic.StallCount + value: 0 + + - kind: SetVariable + id: setVariable_S6HCgh + displayName: Increase Restart count + variable: Topic.RestartCount + value: =Topic.RestartCount + 1 + + - kind: GotoAction + id: goto_LzfJ8u + actionId: question_o3BQkf + + elseActions: + - kind: SendActivity + id: sendActivity_L7ooQO + activity: |- + ({Topic.TypedProgressLedger.next_speaker.reason}) + + {Topic.TypedProgressLedger.next_speaker.answer} - {Topic.TypedProgressLedger.instruction_or_question.answer} + + - kind: SetVariable + id: setVariable_L7ooQO + variable: Topic.StallCount + value: 0 + + - kind: SetVariable + id: setVariable_nxN1mE + variable: Topic.NextSpeaker + value: =Search(Topic.AvailableAgents, Topic.TypedProgressLedger.next_speaker.answer, name) + + - kind: ConditionGroup + id: conditionGroup_QFPiF5 + conditions: + - id: conditionItem_GmigcU + condition: =CountRows(Topic.NextSpeaker) = 1 + displayName: If next Agent tool Exists + actions: + + - kind: AnswerQuestionWithAI + id: question_orsBf06 + variable: Topic.AgentResponse + userInput: =Topic.SeedTask + additionalInstructions: |- + {First(Topic.NextSpeaker).agentid}, + {Topic.TypedProgressLedger.instruction_or_question.answer} + + - kind: SetVariable + id: setVariable_XzNrdM + variable: Topic.AgentResponseText + value: =Topic.AgentResponse.Text + + - kind: ResetVariable + id: setVariable_8eIx2A + displayName: Clear seed task + variable: Topic.SeedTask + + elseActions: + - kind: SendActivity + id: sendActivity_BhcsI7 + activity: Unable to choose next agent... + + - kind: SetVariable + id: setVariable_BhcsI7 + displayName: Increase stall count + variable: Topic.StallCount + value: =Topic.StallCount + 1 + + - kind: GotoAction + id: goto_76Hne8 + actionId: question_o3BQkf diff --git a/workflows/HelloWorld.yaml b/workflows/HelloWorld.yaml new file mode 100644 index 0000000000..8fc434ed60 --- /dev/null +++ b/workflows/HelloWorld.yaml @@ -0,0 +1,30 @@ +# +# This workflow provides the most basic example of providing a response that includes the user and environment input. +# +# No agent setup is required to run this workflow. +# +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: workflow_demo + actions: + + # Capture input + - kind: SetVariable + id: setvar_userinput + variable: Topic.UserInput + value: =System.LastMessageText + + # Capture environment variable + - kind: SetVariable + id: setvar_username + variable: Global.UserName + value: =Env.USERNAME + + # Respond with input + - kind: SendActivity + id: sendActivity_demo + activity: |- + Hello {Global.UserName}, + You said, "{Topic.UserInput}" \ No newline at end of file diff --git a/workflows/MathChat.yaml b/workflows/MathChat.yaml new file mode 100644 index 0000000000..117a241d02 --- /dev/null +++ b/workflows/MathChat.yaml @@ -0,0 +1,82 @@ +# +# This workflow demonstrates conversation between two agents: a student and a teacher. +# The student attempts to solve the input problem and the teacher provides guidance. +# +# For this workflow, two agents are used, each with a prompt specific to their role. +# +# Student: +# See: ./setup/StudentAgent.yaml +# +# With instructions: +# +# Your job is help a math teacher practice teaching by making intentional mistakes. +# You Attempt to solve the given math problem, but with intentional mistakes so the teacher can help. +# Always incorporate the teacher's advice to fix your next response. +# You have the math-skills of a 6th grader. + +# Teacher: +# See: ./setup/TeacherAgent.yaml +# +# With instructions: +# +# Review and coach the student's approach to solving the given math problem. +# Don't repeat the solution or try and solve it. +# If the student has demonstrated comprehension and responded to all of your feedback, +# give the student your congraluations by using the word "congratulations". +# +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: workflow_demo + actions: + + - kind: SetVariable + id: set_project + variable: Topic.Project + value: =System.LastMessage.Text + + - kind: AnswerQuestionWithAI + id: question_student + userInput: =Topic.Project + additionalInstructions: {Env.FOUNDRY_AGENT_STUDENT} + + - kind: ResetVariable + id: reset_project + variable: Topic.Project + + - kind: AnswerQuestionWithAI + id: question_teacher + userInput: ="" + additionalInstructions: {Env.FOUNDRY_AGENT_TEACHER} + + - kind: SetVariable + id: set_count_increment + variable: Topic.TurnCount + value: =Topic.TurnCount + 1 + + - kind: ConditionGroup + id: check_completion + conditions: + + - condition: =!IsBlank(Find("congratulations", Lower(System.LastMessage.Text))) + id: check_turn_done + actions: + + - kind: SendActivity + id: sendActivity_done + activity: GOLD STAR! + + - condition: =Topic.TurnCount < 4 + id: check_turn_count + actions: + + - kind: GotoAction + id: goto_student_agent + actionId: question_student + + elseActions: + + - kind: SendActivity + id: sendActivity_tired + activity: Let's try again later... diff --git a/workflows/Question.yaml b/workflows/Question.yaml new file mode 100644 index 0000000000..22a9d7b6d4 --- /dev/null +++ b/workflows/Question.yaml @@ -0,0 +1,24 @@ +# +# This workflow demonstrates a single agent interaction based on user input. +# +# Any Foundry Agent may be used to provide the response. +# See: ./setup/QuestionAgent.yaml +# +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: workflow_demo + actions: + + # Respond with input + - kind: SendActivity + id: sendActivity_demo + activity: "Working..." + + # Use AI to answer the question + - kind: AnswerQuestionWithAI + id: question_demo + variable: Topic.Answer + userInput: =System.LastMessage.Text + additionalInstructions: {Env.FOUNDRY_AGENT_ANSWER} diff --git a/workflows/README.md b/workflows/README.md new file mode 100644 index 0000000000..c7ceb0d517 --- /dev/null +++ b/workflows/README.md @@ -0,0 +1,27 @@ +# Declarative Workflows + +This folder contains sample workflow definitions than be ran using the +[Declarative Workflow](../dotnet/demos/DeclarativeWorkflow) demo. + +Each workflow is defined in a single YAML file and contains +comments with additional information specific to that workflow. + +A _Declarative Workflow_ may be executed locally no different from any `Workflow` defined by code. +The difference is that the workflow definition is loaded from a YAML file instead of being defined in code. + +```c# +Workflow workflow = DeclarativeWorkflowBuilder.Build("HelloWorld.yaml", options); +``` + +Workflows may also be hosted in your _Azure Foundry Project_. + +> _Python_ support in the works! + +#### Agents + +The sample workflows rely on agents defined in your Azure Foundry Project. + +To create agents, run the [`Create.ps1`](./setup) script. +This will create the agents used in the sample workflows in your Azure Foundry Project and format a script you can copy and use to configure your environment. + +> Note: `Create.ps1` relies upon the `FOUNDRY_PROJECT_ENDPOINT` setting. See [README.md](../dotnet/demos/DeclarativeWorkflow/README.md) from the demo for configuration details. diff --git a/workflows/setup/.gitignore b/workflows/setup/.gitignore new file mode 100644 index 0000000000..ce1409abe9 --- /dev/null +++ b/workflows/setup/.gitignore @@ -0,0 +1,405 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml \ No newline at end of file diff --git a/workflows/setup/AnalystAgent.yaml b/workflows/setup/AnalystAgent.yaml new file mode 100644 index 0000000000..233e65cb22 --- /dev/null +++ b/workflows/setup/AnalystAgent.yaml @@ -0,0 +1,10 @@ +type: foundry_agent +name: ResearchAnalyst +description: Demo agent for DeepResearch workflow +model: + id: ${AzureAI:ModelDeployment} +tools: + - type: bing_grounding + options: + tool_connections: + - ${AzureAI:BingConnectionId} \ No newline at end of file diff --git a/workflows/setup/CoderAgent.yaml b/workflows/setup/CoderAgent.yaml new file mode 100644 index 0000000000..36aa77a7cb --- /dev/null +++ b/workflows/setup/CoderAgent.yaml @@ -0,0 +1,7 @@ +type: foundry_agent +name: ResearchCoder +description: Demo agent for DeepResearch workflow +model: + id: ${AzureAI:ModelDeployment} +tools: + - type: code_interpreter diff --git a/workflows/setup/Create.ps1 b/workflows/setup/Create.ps1 new file mode 100644 index 0000000000..17c7f27fd0 --- /dev/null +++ b/workflows/setup/Create.ps1 @@ -0,0 +1,3 @@ +pushd ./CreateAgents +dotnet run +popd \ No newline at end of file diff --git a/workflows/setup/CreateAgents/CreateAgents.csproj b/workflows/setup/CreateAgents/CreateAgents.csproj new file mode 100644 index 0000000000..4b00b2279f --- /dev/null +++ b/workflows/setup/CreateAgents/CreateAgents.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + enable + enable + 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 + SKEXP0110 + + + + + + + + + + + diff --git a/workflows/setup/CreateAgents/CreateAgents.slnx b/workflows/setup/CreateAgents/CreateAgents.slnx new file mode 100644 index 0000000000..7ff049246b --- /dev/null +++ b/workflows/setup/CreateAgents/CreateAgents.slnx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/workflows/setup/CreateAgents/Program.cs b/workflows/setup/CreateAgents/Program.cs new file mode 100644 index 0000000000..ebd9243c50 --- /dev/null +++ b/workflows/setup/CreateAgents/Program.cs @@ -0,0 +1,68 @@ +using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.AzureAI; +using System.Reflection; +using System.Text; + +// Define FOUNDRY_PROJECT_ENDPOINT as a user-secret or environment variable that +// points to your Foundry project endpoint. + +IConfigurationRoot config = + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); + +string projectEndpoint = config["FOUNDRY_PROJECT_ENDPOINT"] ?? throw new InvalidOperationException("Undefined configuration: FOUNDRY_PROJECT_ENDPOINT"); +Console.WriteLine($"{Environment.NewLine}Foundry: {projectEndpoint}"); + +StringBuilder scriptBuilder = new(); +StringBuilder secretBuilder = new(); +string[] files = args.Length > 0 ? args : Directory.GetFiles(@"..\", "*.yaml"); +foreach (string file in files) +{ + string agentText = await File.ReadAllTextAsync(file); + + PersistentAgentsClient clientAgents = new(projectEndpoint, new AzureCliCredential()); + + AIProjectClient clientProject = new(new Uri(projectEndpoint), new AzureCliCredential()); + + IKernelBuilder kernelBuilder = Kernel.CreateBuilder(); + kernelBuilder.Services.AddSingleton(clientAgents); + kernelBuilder.Services.AddSingleton(clientProject); + Kernel kernel = kernelBuilder.Build(); + + AzureAIAgentFactory factory = new(); + Agent? agent = await factory.CreateAgentFromYamlAsync(agentText, new AgentCreationOptions() { Kernel = kernel }, config); + if (agent is null) + { + Console.WriteLine("Unexpected failure creating agent..."); + continue; + } + + Console.WriteLine(); + Console.WriteLine(Path.GetFileName(file)); + Console.WriteLine($" Id: {agent?.Id ?? "???"}"); + Console.WriteLine($" Name: {agent?.Name ?? agent?.Id}"); + Console.WriteLine($" Note: {agent?.Description}"); + + scriptBuilder.AppendLine($"$env:FOUNDRY_AGENT_{agent?.Name?.ToUpperInvariant()} = '{agent?.Id}'"); + secretBuilder.AppendLine($"dotnet user-secrets set FOUNDRY_AGENT_{agent?.Name?.ToUpperInvariant()} {agent?.Id}"); +} + +Console.WriteLine(); +Console.WriteLine(); +Console.WriteLine("To set these environment variables in your shell, run:"); +Console.WriteLine(); +Console.WriteLine(scriptBuilder); +Console.WriteLine(); +Console.WriteLine(); +Console.WriteLine("To define user secrets, run:"); +Console.WriteLine(); +Console.WriteLine(secretBuilder); +Console.WriteLine(); diff --git a/workflows/setup/ManagerAgent.yaml b/workflows/setup/ManagerAgent.yaml new file mode 100644 index 0000000000..14f718fdf6 --- /dev/null +++ b/workflows/setup/ManagerAgent.yaml @@ -0,0 +1,5 @@ +type: foundry_agent +name: ResearchManager +description: Demo agent for DeepResearch workflow +model: + id: ${AzureAI:ModelDeployment} diff --git a/workflows/setup/QuestionAgent.yaml b/workflows/setup/QuestionAgent.yaml new file mode 100644 index 0000000000..1952259bd6 --- /dev/null +++ b/workflows/setup/QuestionAgent.yaml @@ -0,0 +1,10 @@ +type: foundry_agent +name: Answer +description: Demo agent for Question workflow +model: + id: ${AzureAI:ModelDeployment} +tools: + - type: bing_grounding + options: + tool_connections: + - ${AzureAI:BingConnectionId} \ No newline at end of file diff --git a/workflows/setup/README.md b/workflows/setup/README.md new file mode 100644 index 0000000000..c8e9675ebf --- /dev/null +++ b/workflows/setup/README.md @@ -0,0 +1,14 @@ +# Agent Definitions + +The sample workflows rely on agents defined in your Azure Foundry Project. + +These agent definitions are based on _Semantic Kernel_'s _Declarative Agent_ feature: + +- [Semantic Kernel Agents](https://github.com/microsoft/semantic-kernel/tree/main/dotnet/src/Agents) +- [Declarative Agent Extensions](https://github.com/microsoft/semantic-kernel/tree/main/dotnet/src/Agents/Yaml) +- [Sample](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step08_AzureAIAgent_Declarative.cs) + +To create agents, run the [`Create.ps1`](./Create.ps1) script. +This will create the agents for the sample workflows in your Azure Foundry Project and format a script you can copy and use to configure your environment. + +> Note: `Create.ps1` relies upon the `FOUNDRY_PROJECT_ENDPOINT` setting. See [README.md](../../dotnet/demos/DeclarativeWorkflow/README.md) from the demo for configuration details. diff --git a/workflows/setup/StudentAgent.yaml b/workflows/setup/StudentAgent.yaml new file mode 100644 index 0000000000..fa9c70c7d7 --- /dev/null +++ b/workflows/setup/StudentAgent.yaml @@ -0,0 +1,10 @@ +type: foundry_agent +name: Student +description: Student agent for MathChat workflow +instructions: |- + Your job is help a math teacher practice teaching by making intentional mistakes. + You Attempt to solve the given math problem, but with intentional mistakes so the teacher can help. + Always incorporate the teacher's advice to fix your next response. + You have the math-skills of a 6th grader. +model: + id: ${AzureAI:ModelDeployment} diff --git a/workflows/setup/TeacherAgent.yaml b/workflows/setup/TeacherAgent.yaml new file mode 100644 index 0000000000..27f2b2c761 --- /dev/null +++ b/workflows/setup/TeacherAgent.yaml @@ -0,0 +1,10 @@ +type: foundry_agent +name: Teacher +description: Teacher agent for MathChat workflow +instructions: |- + Review and coach the student's approach to solving the given math problem. + Don't repeat the solution or try and solve it. + If the student has demonstrated comprehension and responded to all of your feedback, + give the student your congraluations by using the word "congratulations". +model: + id: ${AzureAI:ModelDeployment} diff --git a/workflows/setup/WeatherAgent.yaml b/workflows/setup/WeatherAgent.yaml new file mode 100644 index 0000000000..500901997b --- /dev/null +++ b/workflows/setup/WeatherAgent.yaml @@ -0,0 +1,62 @@ +type: foundry_agent +name: ResearchWeather +description: Demo agent for DeepResearch workflow +model: + id: ${AzureAI:ModelDeployment} +tools: + - type: openapi + id: GetCurrentWeather + description: Retrieves current weather data for a location based on wttr.in. + options: + specification: | + { + "openapi": "3.1.0", + "info": { + "title": "Get weather data", + "description": "Retrieves current weather data for a location based on wttr.in.", + "version": "v1.0.0" + }, + "servers": [ + { + "url": "https://wttr.in" + } + ], + "paths": { + "/{location}": { + "get": { + "description": "Get weather information for a specific location", + "operationId": "GetCurrentWeather", + "parameters": [ + { + "name": "location", + "in": "path", + "description": "City or location to retrieve the weather for", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Location not found" + } + }, + "deprecated": false + } + } + }, + "components": { + "schemas": {} + } + } diff --git a/workflows/setup/WebAgent.yaml b/workflows/setup/WebAgent.yaml new file mode 100644 index 0000000000..024dd68f5f --- /dev/null +++ b/workflows/setup/WebAgent.yaml @@ -0,0 +1,15 @@ +type: foundry_agent +name: ResearchWeb +description: Demo agent for DeepResearch workflow +instructions: |- + Only provide requested information in a way that is throughfully organized and formatted. + Never include any analysis or code. + Never generate a file. + Avoid repeating yourself. +model: + id: ${AzureAI:ModelDeployment} +tools: + - type: bing_grounding + options: + tool_connections: + - ${AzureAI:BingConnectionId} \ No newline at end of file diff --git a/workflows/wttr.json b/workflows/wttr.json new file mode 100644 index 0000000000..0b6d7cad9b --- /dev/null +++ b/workflows/wttr.json @@ -0,0 +1,51 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Get weather data", + "description": "Retrieves current weather data for a location based on wttr.in.", + "version": "v1.0.0" + }, + "servers": [ + { + "url": "https://wttr.in" + } + ], + "paths": { + "/{location}": { + "get": { + "description": "Get weather information for a specific location", + "operationId": "GetCurrentWeather", + "parameters": [ + { + "name": "location", + "in": "path", + "description": "City or location to retrieve the weather for", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Location not found" + } + }, + "deprecated": false + } + } + }, + "components": { + "schemas": {} + } +} \ No newline at end of file