From 74879489a4e94ff752ac0fb554710acfea8d2ee5 Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:41:07 -0700 Subject: [PATCH] .NET Workflow - Integrated updated CPS Object Model (#681) * Checkpoint * Update workflows/DeepResearch.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Comment * Fix comment * Update package version * Fix nuget haxx * Checkpoint * Code complete * Testing * Message content workaround * Add sequential flow * Checkpoint * Integration test project * Checkpoint * Checkpoint cleanup * Complete * Update package --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 10 +- dotnet/agent-framework-dotnet.slnx | 7 +- .../Declarative/DeclarativeWorkflow.csproj | 1 - .../Workflows/Declarative/Program.cs | 276 +++++++++++------- .../AzureAgentProvider.cs | 181 ++++++++++++ .../DeclarativeWorkflowBuilder.cs | 3 +- .../DeclarativeWorkflowEvents.cs | 64 ---- .../Entities/EntityExtractionResult.cs | 24 ++ .../Entities/EntityExtractor.cs | 173 +++++++++++ .../Events/ConversationUpdateEvent.cs | 20 ++ .../Events/DeclarativeActionCompleteEvent.cs | 40 +++ .../Events/DeclarativeActionInvokeEvent.cs | 34 +++ .../Events/InputRequest.cs | 19 ++ .../Events/InputResponse.cs | 23 ++ .../Events/MessageActivityEvent.cs | 19 ++ .../Exceptions/DeclarativeModelException.cs | 2 +- .../Extensions/ChatMessageExtensions.cs | 232 ++++++++++++++- .../Extensions/DataValueExtensions.cs | 34 ++- .../Extensions/FormulaValueExtensions.cs | 57 ++-- .../Extensions/IWorkflowContextExtensions.cs | 20 ++ .../Extensions/StringExtensions.cs | 2 +- .../Extensions/TemplateExtensions.cs | 8 +- .../Interpreter/DeclarativeActionExecutor.cs | 47 +-- .../DeclarativeWorkflowExecutor.cs | 2 +- .../Interpreter/DeclarativeWorkflowModel.cs | 47 ++- .../Interpreter/DeclarativeWorkflowState.cs | 6 +- .../Interpreter/DelegateActionExecutor.cs | 32 +- .../Interpreter/DurableProperty.cs | 17 ++ .../Interpreter/ExecutorResultMessage.cs | 16 + .../Interpreter/WorkflowActionVisitor.cs | 162 +++++++--- .../AddConversationMessageExecutor.cs | 66 +++++ .../AnswerQuestionWithAIExecutor.cs | 126 -------- .../ObjectModel/ConditionGroupExecutor.cs | 25 +- .../CopyConversationMessagesExecutor.cs | 45 +++ .../ObjectModel/CreateConversationExecutor.cs | 21 ++ .../ObjectModel/EditTableExecutor.cs | 2 +- .../ObjectModel/EditTableV2Executor.cs | 4 +- .../ObjectModel/ForeachExecutor.cs | 12 +- .../ObjectModel/GotoExecutor.cs | 18 ++ .../ObjectModel/InvokeAzureAgentExecutor.cs | 138 +++++++++ .../ObjectModel/ParseValueExecutor.cs | 2 +- .../ObjectModel/QuestionExecutor.cs | 145 +++++++++ .../RetrieveConversationMessageExecutor.cs | 27 ++ .../RetrieveConversationMessagesExecutor.cs | 67 +++++ .../ObjectModel/SendActivityExecutor.cs | 15 +- .../SetMultipleVariablesExecutor.cs | 39 +++ .../ObjectModel/SetVariableExecutor.cs | 2 +- .../PowerFx/Functions/UserMessage.cs | 36 +++ .../PowerFx/RecalcEngineFactory.cs | 2 + .../PowerFx/SystemScope.cs | 6 - .../PowerFx/TypeSchema.cs | 33 +++ .../PowerFx/WorkflowDiagnostics.cs | 2 +- .../WorkflowAgentProvider.cs | 91 +++--- .../Agents/BasicAgent.yaml | 5 + .../DeclarativeWorkflowTest.cs | 114 ++++++++ .../Framework/AgentFactory.cs | 50 ++++ .../Framework/AgentFixture.cs | 26 ++ .../Framework/TestOutputAdapter.cs | 73 +++++ .../Framework/Testcase.cs | 59 ++++ .../Framework/WorkflowEvents.cs | 23 ++ .../Framework/WorkflowHarness.cs | 17 ++ .../Framework/WorkflowTest.cs | 37 +++ ...kflows.Declarative.IntegrationTests.csproj | 37 +++ .../Testcases/InvokeAgent.json | 12 + .../Testcases/SendActivity.json | 12 + .../Workflows/AddMessages.yaml | 49 ++++ .../Workflows/GetMessage.yaml | 23 ++ .../Workflows/GetMessages.yaml | 22 ++ .../Workflows/InvokeAgent.yaml | 15 + .../Workflows/SendActivity.yaml | 6 +- .../DeclarativeWorkflowTest.cs | 9 +- .../Extensions/FormulaValueExtensionsTests.cs | 28 +- .../ObjectModel/SendActivityExecutorTest.cs | 2 +- .../ObjectModel/WorkflowActionExecutorTest.cs | 8 +- workflows/DeepResearch.yaml | 250 +++++++++------- workflows/HumanInLoop.yaml | 44 +++ workflows/Marketing.yaml | 46 +++ workflows/MathChat.yaml | 22 +- workflows/Question.yaml | 24 -- 79 files changed, 2830 insertions(+), 685 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/AzureAgentProvider.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvents.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Entities/EntityExtractionResult.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Entities/EntityExtractor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/ConversationUpdateEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/DeclarativeActionCompleteEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/DeclarativeActionInvokeEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/InputRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/InputResponse.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/MessageActivityEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DurableProperty.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/ExecutorResultMessage.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/CreateConversationExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/GotoExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/QuestionExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessageExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessagesExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/Functions/UserMessage.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/TypeSchema.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Agents/BasicAgent.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/AgentFixture.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/TestOutputAdapter.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/Testcase.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowEvents.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Microsoft.Agents.Workflows.Declarative.IntegrationTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Testcases/InvokeAgent.json create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Testcases/SendActivity.json create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/AddMessages.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/GetMessage.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/GetMessages.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/InvokeAgent.yaml rename workflows/HelloWorld.yaml => dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/SendActivity.yaml (86%) create mode 100644 workflows/HumanInLoop.yaml create mode 100644 workflows/Marketing.yaml delete mode 100644 workflows/Question.yaml diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 907a58ece8..184812cab2 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -86,14 +86,16 @@ - - - - + + + + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 324d63cb19..2b07e3346d 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -78,9 +78,9 @@ - + + - @@ -96,8 +96,8 @@ - + @@ -265,6 +265,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/DeclarativeWorkflow.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/DeclarativeWorkflow.csproj index 96a9d5df1d..fa80c24ff2 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/DeclarativeWorkflow.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/DeclarativeWorkflow.csproj @@ -7,7 +7,6 @@ $(ProjectsDebugTargetFrameworks) enable disable - 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 $(NoWarn);CA1812 diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/Program.cs index ffacf3123a..914365b779 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/Program.cs @@ -11,6 +11,7 @@ using Azure.AI.Agents.Persistent; using Azure.Identity; using Microsoft.Agents.Workflows; using Microsoft.Agents.Workflows.Declarative; +using Microsoft.Agents.Workflows.Declarative.Events; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -39,17 +40,11 @@ internal sealed class Program private async Task ExecuteAsync() { // Read and parse the declarative workflow. - Notify($"WORKFLOW: Parsing {Path.GetFullPath(this.WorkflowFile)}"); + Notify($"\nWORKFLOW: 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); + Workflow workflow = this.CreateWorkflow(); Notify($"\nWORKFLOW: Defined {timer.Elapsed}"); @@ -57,23 +52,69 @@ internal sealed class Program // 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!"); + CheckpointManager checkpointManager = new(); + Checkpointed run = await InProcessExecution.StreamAsync(workflow, input, checkpointManager); + + bool isComplete = false; + InputResponse? response = null; + do + { + ExternalRequest? inputRequest = await this.MonitorWorkflowRunAsync(run, response); + if (inputRequest is not null) + { + Notify("\nWORKFLOW: Yield"); + + if (this.LastCheckpoint is null) + { + throw new InvalidOperationException("Checkpoint information missing after external request."); + } + + // Process the external request. + response = HandleExternalRequest(inputRequest); + + // Let's resume on an entirely new workflow instance to demonstrate checkpoint portability. + workflow = this.CreateWorkflow(); + + // Restore the latest checkpoint. + Debug.WriteLine($"RESTORE #{this.LastCheckpoint.CheckpointId}"); + Notify("\nWORKFLOW: Restore"); + run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, checkpointManager); + } + else + { + isComplete = true; + } + } + while (!isComplete); + + Notify("\nWORKFLOW: Done!\n"); + } + + private Workflow CreateWorkflow() + { + // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. + DeclarativeWorkflowOptions options = + new(new AzureAgentProvider(this.FoundryEndpoint, new AzureCliCredential())) + { + Configuration = this.Configuration + }; + Workflow workflow = DeclarativeWorkflowBuilder.Build(this.WorkflowFile, options); + return workflow; } 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 static Dictionary NameCache { get; } = []; + private static HashSet FileCache { get; } = []; private string WorkflowFile { get; } private string? WorkflowInput { get; } private string FoundryEndpoint { get; } private PersistentAgentsClient FoundryClient { get; } private IConfiguration Configuration { get; } + private CheckpointInfo? LastCheckpoint { get; set; } private Program(string[] args) { @@ -86,112 +127,147 @@ internal sealed class Program this.FoundryClient = new PersistentAgentsClient(this.FoundryEndpoint, new AzureCliCredential()); } - private async Task MonitorWorkflowRunAsync(StreamingRun run) + private async Task MonitorWorkflowRunAsync(Checkpointed run, InputResponse? response = null) { string? messageId = null; - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + await foreach (WorkflowEvent workflowEvent in run.Run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is ExecutorInvokedEvent executorInvoked) + switch (workflowEvent) { - Debug.WriteLine($"EXECUTOR ENTER #{executorInvoked.ExecutorId}"); - } - else if (evt is ExecutorCompletedEvent executorCompleted) - { - Debug.WriteLine($"EXECUTOR EXIT #{executorCompleted.ExecutorId}"); - } - if (evt is DeclarativeActionInvokeEvent actionInvoked) - { - Debug.WriteLine($"ACTION ENTER #{actionInvoked.ActionId} [{actionInvoked.ActionType}]"); - } - else if (evt is DeclarativeActionCompleteEvent actionComplete) - { - Debug.WriteLine($"ACTION EXIT #{actionComplete.ActionId} [{actionComplete.ActionType}]"); - } - 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; + case ExecutorInvokedEvent executorInvoked: + Debug.WriteLine($"EXECUTOR ENTER #{executorInvoked.ExecutorId}"); + break; - 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}]"); - } - } + case ExecutorCompletedEvent executorCompleted: + Debug.WriteLine($"EXECUTOR EXIT #{executorCompleted.ExecutorId}"); + break; - 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) + case DeclarativeActionInvokeEvent actionInvoked: + Debug.WriteLine($"ACTION ENTER #{actionInvoked.ActionId} [{actionInvoked.ActionType}]"); + break; + + case DeclarativeActionCompleteEvent actionComplete: + Debug.WriteLine($"ACTION EXIT #{actionComplete.ActionId} [{actionComplete.ActionType}]"); + break; + + case ExecutorFailureEvent executorFailure: + Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}"); + break; + + case SuperStepCompletedEvent checkpointCompleted: + this.LastCheckpoint = checkpointCompleted.CompletionInfo?.Checkpoint; + Debug.WriteLine($"CHECKPOINT x{checkpointCompleted.StepNumber} [{this.LastCheckpoint?.CheckpointId ?? "(none)"}]"); + break; + + case RequestInfoEvent requestInfo: + Debug.WriteLine($"REQUEST #{requestInfo.Request.RequestId}"); + if (response is not null) { - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("ACTIVITY:"); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine(messageEvent.Response?.Text.Trim()); + ExternalResponse requestResponse = requestInfo.Request.CreateResponse(response); + await run.Run.SendResponseAsync(requestResponse).ConfigureAwait(false); + response = null; } else { + return requestInfo.Request; + } + break; + + case ConversationUpdateEvent invokeEvent: + Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}"); + break; + + case MessageActivityEvent activityEvent: + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("\nACTIVITY:"); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(activityEvent.Message.Trim()); + break; + + case 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 (!NameCache.TryGetValue(agentId, out string? realName)) + { + PersistentAgent agent = await this.FoundryClient.Administration.GetAgentAsync(agentId); + 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 && 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(); + } + break; + + case AgentRunResponseEvent messageEvent: + try + { + Console.WriteLine(); 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(); - } + finally + { + Console.ResetColor(); + } + break; } } + + return default; + } + private static InputResponse HandleExternalRequest(ExternalRequest request) + { + InputRequest? message = request.Data as InputRequest; + string? userInput = null; + do + { + Console.ForegroundColor = ConsoleColor.DarkGreen; + Console.Write($"\n{message?.Prompt ?? "INPUT:"} "); + Console.ForegroundColor = ConsoleColor.White; + userInput = Console.ReadLine(); + } + while (string.IsNullOrWhiteSpace(userInput)); + + return new InputResponse(userInput); } private static string ParseWorkflowFile(string[] args) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/AzureAgentProvider.cs new file mode 100644 index 0000000000..f88b7fa239 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/AzureAgentProvider.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Azure.Core; +using Azure.Core.Pipeline; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// 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 AzureAgentProvider(string projectEndpoint, TokenCredential projectCredentials, HttpClient? httpClient = null) : WorkflowAgentProvider +{ + private static readonly Dictionary s_roleMap = + new() + { + [ChatRole.User.Value.ToUpperInvariant()] = MessageRole.User, + [ChatRole.Assistant.Value.ToUpperInvariant()] = MessageRole.Agent, + [ChatRole.System.Value.ToUpperInvariant()] = new MessageRole(ChatRole.System.Value), + [ChatRole.Tool.Value.ToUpperInvariant()] = new MessageRole(ChatRole.Tool.Value), + }; + + private PersistentAgentsClient? _agentsClient; + + /// + public override async Task CreateConversationAsync(CancellationToken cancellationToken = default) + { + PersistentAgentThread conversation = await this.GetAgentsClient().Threads.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + return conversation.Id; + } + + /// + public override async Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) + { + await this.GetAgentsClient().Messages.CreateMessageAsync( + conversationId, + role: s_roleMap[conversationMessage.Role.Value.ToUpperInvariant()], + // TODO: PersistentAgent bug blocks supporting multiple content types: + // https://github.com/Azure/azure-sdk-for-net/issues/52571 + //contentBlocks: GetContent(), + content: conversationMessage.Text, + attachments: null, + metadata: GetMetadata(), + cancellationToken).ConfigureAwait(false); + + Dictionary? GetMetadata() + { + if (conversationMessage.AdditionalProperties is null) + { + return null; + } + + return conversationMessage.AdditionalProperties.ToDictionary(prop => prop.Key, prop => prop.Value?.ToString() ?? string.Empty); + } + + // TODO: PersistentAgent bug blocks supporting multiple content types: + // https://github.com/Azure/azure-sdk-for-net/issues/52571 + //IEnumerable GetContent() + //{ + // foreach (AIContent content in conversationMessage.Contents) + // { + // MessageInputContentBlock? contentBlock = + // content switch + // { + // TextContent textContent => new MessageInputTextBlock(textContent.Text), + // HostedFileContent fileContent => new MessageInputImageFileBlock(new MessageImageFileParam(fileContent.FileId)), + // UriContent uriContent when uriContent.Uri is not null => new MessageInputImageUriBlock(new MessageImageUriParam(uriContent.Uri.ToString())), + // _ => null // Unsupported content type + // }; + + // if (contentBlock is not null) + // { + // yield return contentBlock; + // } + // } + //} + } + + /// + public override async Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default) + { + AIAgent agent = await this.GetAgentsClient().GetAIAgentAsync(agentId, chatOptions: null, cancellationToken).ConfigureAwait(false); + + return agent; + } + + /// + public override async Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) + { + PersistentThreadMessage message = await this.GetAgentsClient().Messages.GetMessageAsync(conversationId, messageId, cancellationToken).ConfigureAwait(false); + return ToChatMessage(message); + } + + /// + public override async IAsyncEnumerable GetMessagesAsync( + string conversationId, + int? limit = null, + string? after = null, + string? before = null, + bool newestFirst = false, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ListSortOrder order = newestFirst ? ListSortOrder.Ascending : ListSortOrder.Descending; + await foreach (PersistentThreadMessage message in this.GetAgentsClient().Messages.GetMessagesAsync(conversationId, runId: null, limit, order, after, before, cancellationToken).ConfigureAwait(false)) + { + yield return ToChatMessage(message); + } + } + + 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, clientOptions); + + Interlocked.CompareExchange(ref this._agentsClient, newClient, null); + } + + return this._agentsClient; + } + + private static ChatMessage ToChatMessage(PersistentThreadMessage message) + { + return + new ChatMessage(new ChatRole(message.Role.ToString()), [.. GetContent()]) + { + MessageId = message.Id, + CreatedAt = message.CreatedAt, + AdditionalProperties = GetMetadata() + }; + + IEnumerable GetContent() + { + foreach (MessageContent contentItem in message.ContentItems) + { + AIContent? content = + contentItem switch + { + MessageTextContent textContent => new TextContent(textContent.Text), + MessageImageFileContent imageContent => new HostedFileContent(imageContent.FileId), + _ => null // Unsupported content type + }; + + if (content is not null) + { + yield return content; + } + } + } + + AdditionalPropertiesDictionary? GetMetadata() + { + if (message.Metadata is null) + { + return null; + } + + return new AdditionalPropertiesDictionary(message.Metadata.Select(m => new KeyValuePair(m.Key, m.Value))); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 97a7d93328..a8f0eaad05 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -34,6 +34,7 @@ public static class DeclarativeWorkflowBuilder using StreamReader yamlReader = File.OpenText(workflowFile); return Build(yamlReader, options, inputTransform); } + /// /// Builds a process from the provided YAML definition of a CPS Topic ObjectModel. /// @@ -56,7 +57,7 @@ public static class DeclarativeWorkflowBuilder throw new DeclarativeModelException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(AdaptiveDialog)}."); } - string rootId = WorkflowActionVisitor.RootId(workflowElement.BeginDialog?.Id.Value ?? "workflow"); + string rootId = WorkflowActionVisitor.Steps.Root(workflowElement.BeginDialog?.Id.Value); WorkflowScopes scopes = new(); scopes.Initialize(WrapWithBot(workflowElement), options.Configuration); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvents.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvents.cs deleted file mode 100644 index 74970f62f1..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvents.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.Workflows.Declarative.Extensions; -using Microsoft.Bot.ObjectModel; - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Event that broadcasts the conversation identifier. -/// -public class ConversationUpdateEvent(string executorid, string conversationId) : ExecutorEvent(executorid, conversationId) -{ - /// - /// The conversation ID associated with the workflow. - /// - public string ConversationId { get; } = conversationId; -} - -/// -/// Event that indicates a declarative action has been invoked. -/// -public class DeclarativeActionInvokeEvent(string actionId, DialogAction action, string? priorActionId) : WorkflowEvent(action) -{ - /// - /// The declarative action id. - /// - public string ActionId => actionId; - - /// - /// The declarative action type name. - /// - public string ActionType => action.GetType().Name; - - /// - /// Identifier of the parent action. - /// - public string? ParentActionId => action.GetParentId(); - - /// - /// Identifier of the previous action. - /// - public string? PriorActionId => priorActionId; -} - -/// -/// Event that indicates a declarative action has completed. -/// -public class DeclarativeActionCompleteEvent(string actionId, DialogAction action) : WorkflowEvent(action) -{ - /// - /// The declarative action identifier. - /// - public string ActionId => actionId; - - /// - /// The declarative action type name. - /// - public string ActionType => action.GetType().Name; - - /// - /// Identifier of the parent action. - /// - public string? ParentActionId => action.GetParentId(); -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Entities/EntityExtractionResult.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Entities/EntityExtractionResult.cs new file mode 100644 index 0000000000..96029a7e23 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Entities/EntityExtractionResult.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.Entities; + +internal sealed record class EntityExtractionResult +{ + public EntityExtractionResult(FormulaValue? value) + { + this.Value = value; + this.ErrorMessage = null; + } + + public EntityExtractionResult(string errorMessage) + { + this.Value = null; + this.ErrorMessage = errorMessage; + } + + public FormulaValue? Value { get; } + public string? ErrorMessage { get; } + public bool IsValid => this.Value is not null; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Entities/EntityExtractor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Entities/EntityExtractor.cs new file mode 100644 index 0000000000..7ba0ddac0e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Entities/EntityExtractor.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Mail; +using System.Text.RegularExpressions; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.Entities; + +internal static partial class EntityExtractor +{ + private const string NumberUnitRegExExpression = @"(?[-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\d*\.\d+)"; + +#if NET + [GeneratedRegex(NumberUnitRegExExpression, RegexOptions.IgnoreCase)] + private static partial Regex NumberUnitRegex(); +#else + private static Regex NumberUnitRegex() => s_numberUnitRegex; + private static readonly Regex s_numberUnitRegex = new(NumberUnitRegExExpression, RegexOptions.IgnoreCase | RegexOptions.Compiled); +#endif + + public static EntityExtractionResult Parse(EntityReference? entity, string value) => + entity switch + { + null => UndefinedEntity(value), + AgePrebuiltEntity => TryParseNumberUnit(value, "age"), + BooleanPrebuiltEntity => TryParseBoolean(value), + CityPrebuiltEntity => TryParseString(value), + ColorPrebuiltEntity => TryParseString(value), + ContinentPrebuiltEntity => TryParseString(value), + CountryOrRegionPrebuiltEntity => TryParseString(value), + DatePrebuiltEntity => TryParseDate(value), + DateTimeNoTimeZonePrebuiltEntity => TryParseDateTimeNoTimeZone(value), + DateTimePrebuiltEntity => TryParseDateTime(value), + DurationPrebuiltEntity => TryParseDuration(value), + EmailPrebuiltEntity => TryParseEmail(value), + EventPrebuiltEntity => TryParseString(value), + LanguagePrebuiltEntity => TryParseString(value), + MoneyPrebuiltEntity => TryParseNumberUnit(value, "money"), + NumberPrebuiltEntity => TryParseNumber(value), + PercentagePrebuiltEntity => TryParseNumberUnit(value, "percentage"), + PhoneNumberPrebuiltEntity => TryParseString(value), + PointOfInterestPrebuiltEntity => TryParseString(value), + SpeedPrebuiltEntity => TryParseNumberUnit(value, "speed"), + StatePrebuiltEntity => TryParseString(value), + StreetAddressPrebuiltEntity => TryParseString(value), + StringPrebuiltEntity => TryParseString(value), + TemperaturePrebuiltEntity => TryParseNumberUnit(value, "temperature"), + URLPrebuiltEntity => TryParseURL(value), + WeightPrebuiltEntity => TryParseNumberUnit(value, "weight"), + _ => UnsupportedEntity(entity), + }; + + private static EntityExtractionResult TryParseBoolean(string value) + { + if (bool.TryParse(value, out bool parsedValue)) + { + return new EntityExtractionResult(FormulaValue.New(parsedValue)); + } + + return new EntityExtractionResult($"Invalid boolean value: {value}"); + } + + private static EntityExtractionResult TryParseDate(string value) + { + if (DateTime.TryParse(value, out DateTime parsedValue)) + { + return new EntityExtractionResult(FormulaValue.New(parsedValue.Date)); + } + + return new EntityExtractionResult($"Invalid date value: {value}"); + } + + private static EntityExtractionResult TryParseDateTimeNoTimeZone(string value) + { + if (DateTime.TryParse(value, out DateTime parsedValue)) + { + return new EntityExtractionResult( + FormulaValue.New( + DateTime.SpecifyKind(parsedValue, DateTimeKind.Unspecified))); + } + + return new EntityExtractionResult($"Invalid date value: {value}"); + } + + private static EntityExtractionResult TryParseDateTime(string value) + { + if (DateTime.TryParse(value, out DateTime parsedValue)) + { + return new EntityExtractionResult(FormulaValue.New(parsedValue)); + } + + return new EntityExtractionResult($"Invalid date-time value: {value}"); + } + + private static EntityExtractionResult TryParseDuration(string value) + { + if (TimeSpan.TryParse(value, out TimeSpan parsedValue)) + { + return new EntityExtractionResult(FormulaValue.New(parsedValue)); + } + + return new EntityExtractionResult($"Invalid duration value: {value}"); + } + + private static EntityExtractionResult TryParseEmail(string value) + { + try + { + MailAddress parsedValue = new(value); + return new EntityExtractionResult(FormulaValue.New(parsedValue.Address)); + } + catch + { + return new EntityExtractionResult($"Invalid email value: {value}"); + } + } + + private static EntityExtractionResult TryParseNumberUnit(string value, string type) + { + Match m = NumberUnitRegex().Match(value); + if (m.Success) + { + return new EntityExtractionResult(FormulaValue.New(m.Groups[0].Value)); + } + + return new EntityExtractionResult($"Invalid {type} value: {value}"); + } + + private static EntityExtractionResult TryParseNumber(string value) + { + if (double.TryParse(value, out double parsedValue)) + { + return new EntityExtractionResult(FormulaValue.New(parsedValue)); + } + + return new EntityExtractionResult($"Invalid double value: {value}"); + } + + private static EntityExtractionResult TryParseString(string value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return new EntityExtractionResult(FormulaValue.New(value)); + } + + return new EntityExtractionResult("Empty value"); + } + + private static EntityExtractionResult TryParseURL(string value) + { + if (Uri.TryCreate(value, UriKind.Absolute, out Uri? uriResult)) + { + return new EntityExtractionResult(FormulaValue.New(uriResult.AbsoluteUri)); + } + + return new EntityExtractionResult($"Invalid double value: {value}"); + } + + private static EntityExtractionResult UndefinedEntity(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new EntityExtractionResult(FormulaValue.NewBlank()); + } + + return new EntityExtractionResult(FormulaValue.New(value)); + } + + private static EntityExtractionResult UnsupportedEntity(EntityReference entity) => + new($"Unsupported entity: {entity.GetType().Name}"); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/ConversationUpdateEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/ConversationUpdateEvent.cs new file mode 100644 index 0000000000..9fa9a12ccc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/ConversationUpdateEvent.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Event that broadcasts the conversation identifier. +/// +public sealed class ConversationUpdateEvent : WorkflowEvent +{ + /// + /// The conversation ID associated with the workflow. + /// + public string ConversationId { get; } + + internal ConversationUpdateEvent(string conversationId) + : base(conversationId) + { + this.ConversationId = conversationId; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/DeclarativeActionCompleteEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/DeclarativeActionCompleteEvent.cs new file mode 100644 index 0000000000..b574be1621 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/DeclarativeActionCompleteEvent.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Event that indicates a declarative action has been invoked. +/// +public sealed class DeclarativeActionInvokeEvent : WorkflowEvent +{ + /// + /// The declarative action id. + /// + public string ActionId { get; } + + /// + /// The declarative action type name. + /// + public string ActionType { get; } + + /// + /// Identifier of the parent action. + /// + public string? ParentActionId { get; } + + /// + /// Identifier of the previous action. + /// + public string? PriorActionId { get; } + + internal DeclarativeActionInvokeEvent(DialogAction action, string? priorActionId) : base(action) + { + this.ActionId = action.GetId(); + this.ActionType = action.GetType().Name; + this.ParentActionId = action.GetParentId(); + this.PriorActionId = priorActionId; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/DeclarativeActionInvokeEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/DeclarativeActionInvokeEvent.cs new file mode 100644 index 0000000000..bb9f676635 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/DeclarativeActionInvokeEvent.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Event that indicates a declarative action has completed. +/// +public sealed class DeclarativeActionCompleteEvent : WorkflowEvent +{ + /// + /// The declarative action identifier. + /// + public string ActionId { get; } + + /// + /// The declarative action type name. + /// + public string ActionType { get; } + + /// + /// Identifier of the parent action. + /// + public string? ParentActionId { get; } + + internal DeclarativeActionCompleteEvent(DialogAction action) : base(action) + { + this.ActionId = action.GetId(); + this.ActionType = action.GetType().Name; + this.ParentActionId = action.GetParentId(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/InputRequest.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/InputRequest.cs new file mode 100644 index 0000000000..c2ae30797f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/InputRequest.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Declarative.Events; + +/// +/// Represents a request for user input. +/// +public sealed class InputRequest +{ + /// + /// The prompt message to display to the user. + /// + public string Prompt { get; } + + internal InputRequest(string prompt) + { + this.Prompt = prompt; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/InputResponse.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/InputResponse.cs new file mode 100644 index 0000000000..e72a74b583 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/InputResponse.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Declarative.Events; + +/// +/// Represents a user input response. +/// +public sealed class InputResponse +{ + /// + /// The response value. + /// + public string Value { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The response value. + public InputResponse(string value) + { + this.Value = value; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/MessageActivityEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/MessageActivityEvent.cs new file mode 100644 index 0000000000..1fb786b19f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Events/MessageActivityEvent.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Event that broadcasts the conversation identifier. +/// +public sealed class MessageActivityEvent : WorkflowEvent +{ + /// + /// The conversation ID associated with the workflow. + /// + public string Message { get; } + + internal MessageActivityEvent(string message) : base(message) + { + this.Message = message; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeModelException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeModelException.cs index f6b6adfb11..3f5563cb68 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeModelException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeModelException.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Represents an exception that occurs when the declarative model is not supported. /// -public class DeclarativeModelException : DeclarativeWorkflowException +public sealed class DeclarativeModelException : DeclarativeWorkflowException { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs index 89a65c86b2..32de8a66a6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; +using System.Linq; +using Microsoft.Agents.Workflows.Declarative.PowerFx.Functions; using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.AI; using Microsoft.PowerFx.Types; @@ -9,15 +12,234 @@ 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()); + public static TableValue ToTable(this IEnumerable messages) => + FormulaValue.NewTable(s_messageRecordType, messages.Select(message => message.ToRecord())); + + public static IEnumerable ToChatMessages(this DataValue messages) + { + if (messages is TableDataValue table) + { + return table.ToChatMessages(); + } + + if (messages is RecordDataValue record) + { + return [record.ToChatMessage()]; + } + + if (messages is StringDataValue text) + { + return [text.ToChatMessage()]; + } + + return []; + } + + public static IEnumerable ToChatMessages(this TableDataValue messages) + { + foreach (DataValue message in messages.Values) + { + if (message is RecordDataValue record) + { + if (record.Properties.Count == 1 && record.Properties.TryGetValue("Value", out DataValue? singleColumn)) + { + record = singleColumn as RecordDataValue ?? record; + } + ChatMessage? convertedMessage = record.ToChatMessage(); + if (convertedMessage is not null) + { + yield return convertedMessage; + } + } + else if (message is StringDataValue text) + { + yield return ToChatMessage(text); + } + } + } + + public static ChatMessage? ToChatMessage(this DataValue message) + { + if (message is RecordDataValue record) + { + return record.ToChatMessage(); + } + + if (message is StringDataValue text) + { + return text.ToChatMessage(); + } + + if (message is BlankDataValue) + { + return null; + } + + throw new DeclarativeActionException($"Unable to convert {message.GetDataType()} to {nameof(ChatMessage)}."); + } + + public static ChatMessage ToChatMessage(this RecordDataValue message) => + new(message.GetRole(), [.. message.GetContent()]) + { + AdditionalProperties = message.GetProperty("metadata").ToMetadata() + }; + + public static ChatMessage ToChatMessage(this StringDataValue message) => new(ChatRole.User, message.Value); + + public static AdditionalPropertiesDictionary? ToMetadata(this RecordDataValue? metadata) + { + if (metadata is null) + { + return null; + } + + AdditionalPropertiesDictionary properties = []; + + foreach (KeyValuePair property in metadata.Properties) + { + properties[property.Key] = property.Value.ToObject(); + } + + return properties; + } + + public static ChatRole ToChatRole(this AgentMessageRole role) => + role switch + { + AgentMessageRole.Agent => ChatRole.Assistant, + AgentMessageRole.User => ChatRole.User, + _ => ChatRole.User + }; + + public static ChatRole ToChatRole(this AgentMessageRole? role) => role?.ToChatRole() ?? ChatRole.User; + + public static AIContent? ToContent(this AgentMessageContentType contentType, string? contentValue) + { + if (string.IsNullOrEmpty(contentValue)) + { + return null; + } + + return + contentType switch + { + AgentMessageContentType.ImageUrl => new UriContent(contentValue, "image/*"), + AgentMessageContentType.ImageFile => new HostedFileContent(contentValue), + _ => new TextContent(contentValue) + }; + } + + private static ChatRole GetRole(this RecordDataValue message) + { + StringDataValue? roleValue = message.GetProperty(TypeSchema.Message.Fields.Role); + if (roleValue is null || string.IsNullOrWhiteSpace(roleValue.Value)) + { + return ChatRole.User; + } + + AgentMessageRole? role = null; + if (Enum.TryParse(roleValue.Value, out AgentMessageRole parsedRole)) + { + role = parsedRole; + } + + return role.ToChatRole(); + } + + private static IEnumerable GetContent(this RecordDataValue message) + { + TableDataValue? content = message.GetProperty(TypeSchema.Message.Fields.Content); + if (content is not null) + { + foreach (RecordDataValue contentItem in content.Values) + { + StringDataValue? contentValue = contentItem?.GetProperty(TypeSchema.Message.Fields.ContentValue); + if (contentValue is null || string.IsNullOrWhiteSpace(contentValue.Value)) + { + continue; + } + yield return + contentItem?.GetProperty(TypeSchema.Message.Fields.ContentType)?.Value switch + { + TypeSchema.Message.ContentTypes.ImageUrl => new UriContent(contentValue.Value, "image/*"), + TypeSchema.Message.ContentTypes.ImageFile => new HostedFileContent(contentValue.Value), + _ => new TextContent(contentValue.Value) + }; + } + } + } + + private static TValue? GetProperty(this RecordDataValue record, string name) + where TValue : DataValue + { + if (record.Properties.TryGetValue(name, out DataValue? value) && value is TValue dataValue) + { + return dataValue; + } + + return null; + } + 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()); + yield return new NamedValue(TypeSchema.Message.Fields.Id, message.MessageId.ToFormula()); + yield return new NamedValue(TypeSchema.Message.Fields.Role, message.Role.Value.ToFormula()); + yield return new NamedValue(TypeSchema.Message.Fields.Author, message.AuthorName.ToFormula()); + yield return new NamedValue(TypeSchema.Message.Fields.Content, TableValue.NewTable(s_contentRecordType, message.GetContentRecords())); + yield return new NamedValue(TypeSchema.Message.Fields.Text, message.Text.ToFormula()); + yield return new NamedValue(TypeSchema.Message.Fields.Metadata, message.AdditionalProperties.ToRecord()); } + + private static IEnumerable GetContentRecords(this ChatMessage message) => + message.Contents.Select(content => RecordValue.NewRecordFromFields(content.GetContentFields())); + + private static IEnumerable GetContentFields(this AIContent content) + { + return + content switch + { + UriContent uriContent => CreateContentRecord(TypeSchema.Message.ContentTypes.ImageUrl, uriContent.Uri.ToString()), + HostedFileContent fileContent => CreateContentRecord(TypeSchema.Message.ContentTypes.ImageFile, fileContent.FileId), + TextContent textContent => CreateContentRecord(TypeSchema.Message.ContentTypes.Text, textContent.Text), + _ => [] + }; + + static IEnumerable CreateContentRecord(string type, string value) + { + yield return new NamedValue(TypeSchema.Message.Fields.ContentType, type.ToFormula()); + yield return new NamedValue(TypeSchema.Message.Fields.ContentValue, value.ToFormula()); + } + } + private static RecordValue ToRecord(this AdditionalPropertiesDictionary? value) + { + return FormulaValue.NewRecordFromFields(GetFields()); + + IEnumerable GetFields() + { + if (value is not null) + { + foreach (string key in value.Keys) + { + yield return new NamedValue(key, value[key].ToFormula()); + } + } + } + } + + private static readonly RecordType s_contentRecordType = + RecordType.Empty() + .Add(TypeSchema.Message.Fields.ContentType, FormulaType.String) + .Add(TypeSchema.Message.Fields.ContentValue, FormulaType.String); + + private static readonly RecordType s_messageRecordType = + RecordType.Empty() + .Add(TypeSchema.Message.Fields.Id, FormulaType.String) + .Add(TypeSchema.Message.Fields.Role, FormulaType.String) + .Add(TypeSchema.Message.Fields.Author, FormulaType.String) + .Add(TypeSchema.Message.Fields.Content, s_contentRecordType.ToTable()) + .Add(TypeSchema.Message.Fields.Text, FormulaType.String) + .Add(TypeSchema.Message.Fields.Metadata, RecordType.Empty()); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs index 81666b6013..687cc302f2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -9,11 +9,11 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; internal static class DataValueExtensions { - public static FormulaValue ToFormulaValue(this DataValue? value) => + public static FormulaValue ToFormula(this DataValue? value) => value switch { null => FormulaValue.NewBlank(), - BlankDataValue blankValue => BlankValue.NewBlank(), + BlankDataValue => BlankValue.NewBlank(), BooleanDataValue boolValue => FormulaValue.New(boolValue.Value), NumberDataValue numberValue => FormulaValue.New(numberValue.Value), FloatDataValue floatValue => FormulaValue.New(floatValue.Value), @@ -53,12 +53,30 @@ internal static class DataValueExtensions _ => FormulaType.Unknown, }; + public static object? ToObject(this DataValue? value) => + value switch + { + null => null, + BlankDataValue => null, + BooleanDataValue boolValue => boolValue.Value, + NumberDataValue numberValue => (numberValue.Value), + FloatDataValue floatValue => (floatValue.Value), + StringDataValue stringValue => (stringValue.Value), + DateTimeDataValue dateTimeValue => (dateTimeValue.Value.DateTime), + DateDataValue dateValue => dateValue.Value, + TimeDataValue timeValue => timeValue.Value, + TableDataValue tableValue => tableValue.Values.Select(value => value.ToRecordValue()).ToArray(), + RecordDataValue recordValue => recordValue.ToDictionary(), + OptionDataValue optionValue => optionValue.Value.Value, + _ => throw new DeclarativeModelException($"Unsupported {nameof(DataValue)} type: {value.GetType().Name}"), + }; + 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()))); + property => new NamedValue(property.Key, property.Value.ToFormula()))); public static RecordType ToRecordType(this RecordDataType record) { @@ -79,4 +97,14 @@ internal static class DataValueExtensions } return recordType; } + + private static Dictionary ToDictionary(this RecordDataValue record) + { + Dictionary result = []; + foreach (KeyValuePair property in record.Properties) + { + result[property.Key] = property.Value.ToObject(); + } + return result; + } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index 568f3ba104..278a6b0ba9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -20,7 +20,7 @@ internal static class FormulaValueExtensions public static FormulaValue NewBlank(this FormulaType? type) => FormulaValue.NewBlank(type ?? FormulaType.Blank); - public static FormulaValue ToFormulaValue(this object? value) => + public static FormulaValue ToFormula(this object? value) => value switch { null => FormulaValue.NewBlank(), @@ -35,8 +35,9 @@ internal static class FormulaValueExtensions 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(), + object when value is IDictionary dictionaryValue => dictionaryValue.ToRecord(), + object when value is IEnumerable tableValue => tableValue.ToTable(), _ => throw new DeclarativeModelException($"Unsupported variable type: {value.GetType().Name}"), }; @@ -143,6 +144,19 @@ internal static class FormulaValueExtensions public static RecordDataValue ToRecord(this RecordValue value) => RecordDataValue.RecordFromFields(value.OriginalFields.Select(field => field.GetKeyValuePair()).ToImmutableArray()); + private static RecordValue ToRecord(this IDictionary value) + { + return FormulaValue.NewRecordFromFields(GetFields()); + + IEnumerable GetFields() + { + foreach (string key in value.Keys) + { + yield return new NamedValue(key, value[key].ToFormula()); + } + } + } + private static RecordDataType ToDataType(this RecordType record) { RecordDataType recordType = new(); @@ -176,48 +190,27 @@ internal static class FormulaValueExtensions private static RecordValue ToRecord(this ExpandoObject value) => FormulaValue.NewRecordFromFields( value.Select( - property => new NamedValue(property.Key, property.Value.ToFormulaValue()))); + property => new NamedValue(property.Key, property.Value.ToFormula()))); private static TableType ToTableType(this IEnumerable value) { - Type valueType = value.GetType(); - Type? elementType = valueType.GetElementType() ?? valueType.GetGenericArguments().FirstOrDefault(); - - if (elementType is not null) + foreach (object? element in value) { - if (elementType != typeof(ExpandoObject)) + if (element is not ExpandoObject expandoElement) { - throw new DeclarativeModelException($"Invalid table element: {elementType.Name}"); + throw new DeclarativeModelException($"Invalid table element: {element.GetType().Name}"); } - foreach (ExpandoObject element in value) - { - return element.ToRecordType().ToTable(); - } + return expandoElement.ToRecordType().ToTable(); // Return first element } 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 TableValue ToTable(this IEnumerable value) => + FormulaValue.NewTable( + value.ToTableType().ToRecord(), + [.. value.OfType().Select(element => element.ToRecord())]); private static KeyValuePair GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.ToDataValue()); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs new file mode 100644 index 0000000000..fb4312ec42 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class IWorkflowContextExtensions +{ + public static ValueTask RaiseInvocationEventAsync(this IWorkflowContext context, DialogAction action, string? priorEventId = null) => + context.AddEventAsync(new DeclarativeActionInvokeEvent(action, priorEventId)); + + public static ValueTask RaiseCompletionEventAsync(this IWorkflowContext context, DialogAction action) => + context.AddEventAsync(new DeclarativeActionCompleteEvent(action)); + + public static ValueTask SendResultMessageAsync(this IWorkflowContext context, string id, object? result = null, CancellationToken cancellationToken = default) => + context.SendMessageAsync(new ExecutorResultMessage(id, result)); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs index 0f3a078d2c..83099fddee 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs @@ -20,6 +20,6 @@ internal static class StringExtensions return value.Trim(); } - public static FormulaValue ToFormulaValue(this string? value) => + public static FormulaValue ToFormula(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 index 9a41554a18..bf13587a38 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs @@ -10,21 +10,21 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; internal static class TemplateExtensions { - public static string? Format(this RecalcEngine engine, IEnumerable template) + 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) + 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) + public static string Format(this RecalcEngine engine, TemplateSegment segment) { if (segment is TextSegment textSegment) { - return textSegment.Value; + return textSegment.Value ?? string.Empty; } if (segment is ExpressionSegment expressionSegment) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 2fa157bcca..e8978347fd 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -15,19 +15,15 @@ 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) + DeclarativeActionExecutor(model, state) where TAction : DialogAction { public new TAction Model => (TAction)base.Model; } -internal abstract class WorkflowActionExecutor : Executor +internal abstract class DeclarativeActionExecutor : Executor { - public const string RootActionId = "(root)"; - private static readonly ImmutableHashSet s_mutableScopes = new HashSet { @@ -37,7 +33,7 @@ internal abstract class WorkflowActionExecutor : Executor this._parentId ??= this.Model.GetParentId() ?? RootActionId; + public string ParentId => this._parentId ??= this.Model.GetParentId() ?? WorkflowActionVisitor.Steps.Root(); - internal ILogger Logger { get; set; } = NullLogger.Instance; + internal ILogger Logger { get; set; } = NullLogger.Instance; protected DeclarativeWorkflowState State { get; } protected virtual bool IsDiscreteAction => true; + protected virtual bool EmitResultEvent => true; + /// - public override async ValueTask HandleAsync(DeclarativeExecutorResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(ExecutorResultMessage message, IWorkflowContext context) { if (this.Model.Disabled) { @@ -68,15 +66,18 @@ internal abstract class WorkflowActionExecutor : Executor ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default); - protected async ValueTask AssignAsync(PropertyPath targetPath, FormulaValue result, IWorkflowContext context) + /// + /// Restore the state of the executor from a checkpoint. + /// This must be overridden to restore any state that was saved during checkpointing. + /// + protected override ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellation = default) => + this.State.RestoreAsync(context, cancellation); + + protected async ValueTask AssignAsync(PropertyPath? targetPath, FormulaValue result, IWorkflowContext context) { + if (targetPath is null) + { + return; + } + if (!s_mutableScopes.Contains(Throw.IfNull(targetPath.VariableScopeName))) { throw new DeclarativeModelException($"Invalid scope: {targetPath.VariableScopeName}"); @@ -125,8 +138,4 @@ internal abstract class WorkflowActionExecutor : Executor context.AddEventAsync(new DeclarativeActionInvokeEvent(this.Id, this.Model, priorEventId)); - - protected ValueTask RaiseCompletionEventAsync(IWorkflowContext context) => context.AddEventAsync(new DeclarativeActionCompleteEvent(this.Id, this.Model)); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index f65bb7f5f6..9e6a39d50f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -22,6 +22,6 @@ internal sealed class DeclarativeWorkflowExecutor( ChatMessage input = inputTransform.Invoke(message); await state.SetLastMessageAsync(context, input).ConfigureAwait(false); - await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id)).ConfigureAwait(false); + await context.SendMessageAsync(new ExecutorResultMessage(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 index 1d8f511f55..3bff3ad5db 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs @@ -43,7 +43,19 @@ internal sealed class DeclarativeWorkflowModel throw new DeclarativeModelException($"Unresolved parent for {executor.Id}: {parentId}."); } - ModelNode stepNode = this.DefineNode(executor, parentNode, executor.GetType(), completionHandler); + ModelNode stepNode = this.DefineNode(executor, parentNode, completionHandler); + + parentNode.Children.Add(stepNode); + } + + public void AddPort(InputPort port, string parentId) + { + if (!this.Nodes.TryGetValue(parentId, out ModelNode? parentNode)) + { + throw new DeclarativeModelException($"Unresolved parent for {port.Id}: {parentId}."); + } + + ModelNode stepNode = this.DefineNode(port, parentNode); parentNode.Children.Add(stepNode); } @@ -96,13 +108,32 @@ internal sealed class DeclarativeWorkflowModel Debug.WriteLine($"> CONNECT: {link.Source.Id} => {link.TargetId}{(link.Condition is null ? string.Empty : " (?)")}"); - workflowBuilder.AddEdge(link.Source.Executor, targetNode.Executor, link.Condition); + workflowBuilder.AddEdge(GetExecutorIsh(link.Source), GetExecutorIsh(targetNode), link.Condition); + } + + ExecutorIsh GetExecutorIsh(ModelNode node) + { + if (node.Port is not null) + { + return node.Port; + } + + return node.Executor; } } - private ModelNode DefineNode(Executor executor, ModelNode? parentNode = null, Type? executorType = null, Action? completionHandler = null) + private ModelNode DefineNode(Executor executor, ModelNode? parentNode = null, Action? completionHandler = null) { - ModelNode stepNode = new(executor, parentNode, executorType, completionHandler); + ModelNode stepNode = new(executor, port: null, parentNode, completionHandler); + + this.Nodes.Add(stepNode.Id, stepNode); + + return stepNode; + } + + private ModelNode DefineNode(InputPort port, ModelNode? parentNode = null) + { + ModelNode stepNode = new(executor: null!, port, parentNode); this.Nodes.Add(stepNode.Id, stepNode); @@ -134,13 +165,15 @@ internal sealed class DeclarativeWorkflowModel return null; } - private sealed class ModelNode(Executor executor, ModelNode? parent = null, Type? executorType = null, Action? completionHandler = null) + private sealed class ModelNode(Executor executor, InputPort? port, ModelNode? parent = null, Action? completionHandler = null) { - public string Id => executor.Id; + public string Id => port?.Id ?? executor.Id; public Executor Executor => executor; - public Type? ExecutorType => executorType; + public InputPort? Port => port; + + public Type? ExecutorType => this.Port?.GetType() ?? this.Executor.GetType(); public ModelNode? Parent { get; } = parent; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs index fbbc77c48d..adcfba8798 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs @@ -77,9 +77,9 @@ internal sealed class DeclarativeWorkflowState await context.QueueStateUpdateAsync(varName, value.ToObject(), scopeName).ConfigureAwait(false); } - public string? Format(IEnumerable template) => this._engine.Format(template); + public string Format(IEnumerable template) => this._engine.Format(template); - public string? Format(TemplateLine? line) => this._engine.Format(line); + public string Format(TemplateLine? line) => this._engine.Format(line); public async ValueTask RestoreAsync(IWorkflowContext context, CancellationToken cancellationToken) { @@ -96,7 +96,7 @@ internal sealed class DeclarativeWorkflowState foreach (string key in keys) { object? value = await context.ReadStateAsync(key, scopeName).ConfigureAwait(false); - this._scopes.Set(key, value.ToFormulaValue(), scopeName); + this._scopes.Set(key, value.ToFormula(), scopeName); } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs index 59afb516ff..d9186b1901 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs @@ -1,29 +1,47 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; -internal delegate ValueTask DelegateAction(IWorkflowContext context, CancellationToken cancellationToken); +internal delegate ValueTask DelegateAction(IWorkflowContext context, TMessage message, CancellationToken cancellationToken) where TMessage : notnull; -internal sealed class DelegateActionExecutor : Executor +internal sealed class DelegateActionExecutor(string actionId, DelegateAction? action = null, bool emitResult = true) + : DelegateActionExecutor(actionId, action, emitResult) { - private readonly DelegateAction? _action; + public override ValueTask HandleAsync(ExecutorResultMessage message, IWorkflowContext context) + { + Debug.WriteLine($"RESULT #{this.Id} - {message.Result ?? "(null)"}"); - public DelegateActionExecutor(string actionId, DelegateAction? action = null) + return base.HandleAsync(message, context); + } +} + +internal class DelegateActionExecutor : Executor where TMessage : notnull +{ + private readonly DelegateAction? _action; + private readonly bool _emitResult; + + public DelegateActionExecutor(string actionId, DelegateAction? action = null, bool emitResult = true) : base(actionId) { this._action = action; + this._emitResult = emitResult; } - public override async ValueTask HandleAsync(DeclarativeExecutorResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(TMessage message, IWorkflowContext context) { if (this._action is not null) { - await this._action.Invoke(context, default).ConfigureAwait(false); + await this._action.Invoke(context, message, default).ConfigureAwait(false); } - await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id)).ConfigureAwait(false); + if (this._emitResult) + { + await context.SendResultMessageAsync(this.Id).ConfigureAwait(false); + } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DurableProperty.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DurableProperty.cs new file mode 100644 index 0000000000..fbe02be701 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DurableProperty.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; + +internal sealed class DurableProperty(string name) where TValue : struct +{ + public async ValueTask ReadAsync(IWorkflowContext context) + { + TValue? storedValue = await context.ReadStateAsync(name).ConfigureAwait(false); + return storedValue ?? default; + } + + public ValueTask WriteAsync(IWorkflowContext context, TValue value) => + context.QueueStateUpdateAsync(name, value); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/ExecutorResultMessage.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/ExecutorResultMessage.cs new file mode 100644 index 0000000000..99d3a71984 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/ExecutorResultMessage.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; + +internal sealed record class ExecutorResultMessage(string ExecutorId, object? Result = null) +{ + public static ExecutorResultMessage ThrowIfNot(object? message) + { + if (message is not ExecutorResultMessage executorMessage) + { + throw new DeclarativeActionException($"Unexpected message type: {message?.GetType().Name ?? "(null)"} (Expected: {nameof(ExecutorResultMessage)})"); + } + + return executorMessage; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 8434308fc1..a2675bd5d4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; using System.Linq; +using Microsoft.Agents.Workflows.Declarative.Events; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Bot.ObjectModel; @@ -11,6 +12,15 @@ namespace Microsoft.Agents.Workflows.Declarative.Interpreter; internal sealed class WorkflowActionVisitor : DialogActionVisitor { + private const string DefaultWorkflowId = "workflow"; + + internal static class Steps + { + public static string Root(string? actionId = null) => $"{actionId ?? DefaultWorkflowId}_{nameof(Root)}"; + + public static string Post(string actionId) => $"{actionId}_{nameof(Post)}"; + } + private readonly WorkflowBuilder _workflowBuilder; private readonly DeclarativeWorkflowModel _workflowModel; private readonly DeclarativeWorkflowOptions _workflowOptions; @@ -47,10 +57,10 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor // Handle case where root element is its own parent if (item.Id.Equals(parentId)) { - parentId = RootId(parentId); + parentId = Steps.Root(parentId); } - this.ContinueWith(this.CreateStep(item.Id.Value), parentId, condition: null, CompletionHandler); + this.ContinueWith(new DelegateActionExecutor(item.Id.Value), parentId, condition: null, CompletionHandler); // Complete the action scope. void CompletionHandler() @@ -59,7 +69,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor { 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 + this._workflowModel.AddLink(completionId, Steps.Post(parentId)); // Merge with parent scope } } } @@ -73,7 +83,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor { string stepId = ConditionGroupExecutor.Steps.Item(conditionGroup.Model, item); string parentId = GetParentId(item); - this._workflowModel.AddNode(this.CreateStep(stepId), parentId, CompletionHandler); + this._workflowModel.AddNode(new DelegateActionExecutor(stepId), parentId, CompletionHandler); base.VisitConditionItem(item); @@ -81,7 +91,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor void CompletionHandler() { string completionId = this.ContinuationFor(stepId, conditionGroup.DoneAsync); // End items - this._workflowModel.AddLink(completionId, PostId(conditionGroup.Id)); // Merge with parent scope + this._workflowModel.AddLink(completionId, Steps.Post(conditionGroup.Id)); // Merge with parent scope // Merge link when no action group is defined if (!item.Actions.Any()) @@ -117,6 +127,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor // 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)); @@ -127,10 +138,10 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor { 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); + GotoExecutor action = new(item, this._workflowState); + this.ContinueWith(action); + this._workflowModel.AddLink(action.Id, item.ActionId.Value); + this.RestartAfter(action.Id, action.ParentId); } protected override void Visit(Foreach item) @@ -140,17 +151,18 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor 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 + this.ContinueWith(new DelegateActionExecutor(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); + + string startId = ForeachExecutor.Steps.Start(action.Id); + this._workflowModel.AddNode(new DelegateActionExecutor(startId), action.Id); + this._workflowModel.AddLink(loopId, startId, (_) => action.HasValue); void CompletionHandler() { string endActionsId = ForeachExecutor.Steps.End(action.Id); // Loop continuation - this.ContinueWith(this.CreateStep(endActionsId, action.ResetAsync), action.Id); + this.ContinueWith(new DelegateActionExecutor(endActionsId, action.ResetAsync), action.Id); this._workflowModel.AddLink(endActionsId, loopId); } } @@ -163,8 +175,8 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor 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.ContinueWith(new DelegateActionExecutor(item.Id.Value), parentId); + this._workflowModel.AddLink(item.Id.Value, Steps.Post(loopExecutor.Id)); this.RestartAfter(item.Id.Value, parentId); } } @@ -177,7 +189,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor if (loopExecutor is not null) { string parentId = GetParentId(item); - this.ContinueWith(this.CreateStep(item.Id.Value), parentId); + this.ContinueWith(new DelegateActionExecutor(item.Id.Value), parentId); this._workflowModel.AddLink(item.Id.Value, ForeachExecutor.Steps.Next(loopExecutor.Id)); this.RestartAfter(item.Id.Value, parentId); } @@ -188,15 +200,77 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor this.Trace(item); string parentId = GetParentId(item); - this.ContinueWith(this.CreateStep(item.Id.Value), parentId); + this.ContinueWith(new DelegateActionExecutor(item.Id.Value), parentId); this.RestartAfter(item.Id.Value, parentId); } - protected override void Visit(AnswerQuestionWithAI item) + protected override void Visit(Question item) { this.Trace(item); - this.ContinueWith(new AnswerQuestionWithAIExecutor(item, this._workflowOptions.AgentProvider, this._workflowState)); + string parentId = GetParentId(item); + string actionId = item.GetId(); + string postId = Steps.Post(actionId); + + QuestionExecutor questionExecutor = new(item, this._workflowState); + this.ContinueWith(questionExecutor); + this._workflowModel.AddLink(actionId, postId, message => questionExecutor.IsComplete(message)); + + string prepareId = QuestionExecutor.Steps.Prepare(actionId); + this.ContinueWith(new DelegateActionExecutor(prepareId, questionExecutor.PrepareResponseAsync, emitResult: false), parentId, message => !questionExecutor.IsComplete(message)); + + string inputId = QuestionExecutor.Steps.Input(actionId); + InputPort inputPort = InputPort.Create(inputId); + this._workflowModel.AddPort(inputPort, parentId); + this._workflowModel.AddLinkFromPeer(parentId, inputId); + + string captureId = QuestionExecutor.Steps.Capture(actionId); + this.ContinueWith(new DelegateActionExecutor(captureId, questionExecutor.CaptureResponseAsync, emitResult: false), parentId); + + this.ContinueWith(new DelegateActionExecutor(postId, questionExecutor.CompleteAsync), parentId, message => questionExecutor.IsComplete(message)); + this._workflowModel.AddLink(captureId, prepareId, message => !questionExecutor.IsComplete(message)); + } + + protected override void Visit(CreateConversation item) + { + this.Trace(item); + + this.ContinueWith(new CreateConversationExecutor(item, this._workflowOptions.AgentProvider, this._workflowState)); + } + + protected override void Visit(AddConversationMessage item) + { + this.Trace(item); + + this.ContinueWith(new AddConversationMessageExecutor(item, this._workflowOptions.AgentProvider, this._workflowState)); + } + + protected override void Visit(CopyConversationMessages item) + { + this.Trace(item); + + this.ContinueWith(new CopyConversationMessagesExecutor(item, this._workflowOptions.AgentProvider, this._workflowState)); + } + + protected override void Visit(InvokeAzureAgent item) + { + this.Trace(item); + + this.ContinueWith(new InvokeAzureAgentExecutor(item, this._workflowOptions.AgentProvider, this._workflowState)); + } + + protected override void Visit(RetrieveConversationMessage item) + { + this.Trace(item); + + this.ContinueWith(new RetrieveConversationMessageExecutor(item, this._workflowOptions.AgentProvider, this._workflowState)); + } + + protected override void Visit(RetrieveConversationMessages item) + { + this.Trace(item); + + this.ContinueWith(new RetrieveConversationMessagesExecutor(item, this._workflowOptions.AgentProvider, this._workflowState)); } protected override void Visit(SetVariable item) @@ -206,6 +280,13 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor this.ContinueWith(new SetVariableExecutor(item, this._workflowState)); } + protected override void Visit(SetMultipleVariables item) + { + this.Trace(item); + + this.ContinueWith(new SetMultipleVariablesExecutor(item, this._workflowState)); + } + protected override void Visit(SetTextVariable item) { this.Trace(item); @@ -257,6 +338,11 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor #region Not supported + protected override void Visit(AnswerQuestionWithAI item) + { + this.NotSupported(item); + } + protected override void Visit(DeleteActivity item) { this.NotSupported(item); @@ -317,11 +403,6 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor this.NotSupported(item); } - protected override void Visit(Question item) - { - this.NotSupported(item); - } - protected override void Visit(CSATQuestion item) { this.NotSupported(item); @@ -435,7 +516,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor #endregion private void ContinueWith( - WorkflowActionExecutor executor, + DeclarativeActionExecutor executor, Func? condition = null, Action? completionHandler = null) { @@ -453,32 +534,21 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor this._workflowModel.AddLinkFromPeer(parentId, executor.Id, condition); } - public static string RootId(string? actionId) => $"root_{actionId ?? "workflow"}"; + private string ContinuationFor(string parentId, DelegateAction? stepAction = null) => this.ContinuationFor(parentId, parentId, stepAction); - 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, DelegateAction? stepAction = null) => this.ContinuationFor(parentId, parentId, stepAction); - - private string ContinuationFor(string actionId, string parentId, DelegateAction? stepAction = null) + private string ContinuationFor(string actionId, string parentId, DelegateAction? stepAction = null) { - actionId = PostId(actionId); - this._workflowModel.AddNode(this.CreateStep(actionId, stepAction), parentId); + actionId = Steps.Post(actionId); + this._workflowModel.AddNode(new DelegateActionExecutor(actionId, stepAction), parentId); return actionId; } private void RestartAfter(string actionId, string parentId) => - this._workflowModel.AddNode(this.CreateStep($"{actionId}_Continue"), parentId); + this._workflowModel.AddNode(new DelegateActionExecutor($"{actionId}_Continue"), parentId); - private DelegateActionExecutor CreateStep(string actionId, DelegateAction? stepAction = null) - { - DelegateActionExecutor stepExecutor = new(actionId, stepAction); - - return stepExecutor; - } + private static string GetParentId(BotElement item) => + item.GetParentId() ?? + throw new DeclarativeModelException($"Missing parent ID for action element: {item.GetId()} [{item.GetType().Name}]."); private void NotSupported(DialogAction item) { @@ -496,7 +566,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor string? parentId = item.GetParentId(); if (item.Id.Equals(parentId ?? string.Empty)) { - parentId = RootId(parentId); + parentId = Steps.Root(parentId); } Debug.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(parentId))}{FormatItem(item)} => {FormatParent(item)}"); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs new file mode 100644 index 0000000000..79400a339e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +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.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class AddConversationMessageExecutor(AddConversationMessage model, WorkflowAgentProvider agentProvider, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + StringExpression conversationExpression = Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}"); + string conversationId = this.State.ExpressionEngine.GetValue(conversationExpression).Value; + + ChatMessage newMessage = new(this.GetRole(), [.. this.GetContent()]) { AdditionalProperties = this.GetMetadata() }; + + await agentProvider.CreateMessageAsync(conversationId, newMessage, cancellationToken).ConfigureAwait(false); + + await this.AssignAsync(this.Model.Message?.Path, newMessage.ToRecord(), context).ConfigureAwait(false); + + return default; + } + + private IEnumerable GetContent() + { + foreach (AddConversationMessageContent content in this.Model.Content) + { + AIContent? messageContent = content.Type.Value.ToContent(this.State.Format(content.Value)); + if (messageContent is not null) + { + yield return messageContent; + } + } + } + + private ChatRole GetRole() + { + if (this.Model.Role is null) + { + return ChatRole.User; + } + + AgentMessageRoleWrapper roleWrapper = this.State.ExpressionEngine.GetValue(this.Model.Role).Value; + + return roleWrapper.Value.ToChatRole(); + } + + private AdditionalPropertiesDictionary? GetMetadata() + { + if (this.Model.Metadata is null) + { + return null; + } + + RecordDataValue? metadataValue = this.State.ExpressionEngine.GetValue(this.Model.Metadata).Value; + + return metadataValue.ToMetadata(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs deleted file mode 100644 index 15806e38ac..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ /dev/null @@ -1,126 +0,0 @@ -// 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/ConditionGroupExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs index b8d1662210..76b735bc84 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs @@ -3,6 +3,7 @@ using System; 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; @@ -33,24 +34,16 @@ internal sealed class ConditionGroupExecutor : DeclarativeActionExecutor false; - public bool IsMatch(ConditionItem conditionItem, object? result) + public bool IsMatch(ConditionItem conditionItem, object? message) { - if (result is not DeclarativeExecutorResult message) - { - return false; - } - - return string.Equals(Steps.Item(this.Model, conditionItem), message.Result as string, StringComparison.Ordinal); + ExecutorResultMessage executorMessage = ExecutorResultMessage.ThrowIfNot(message); + return string.Equals(Steps.Item(this.Model, conditionItem), executorMessage.Result as string, StringComparison.Ordinal); } - public bool IsElse(object? result) + public bool IsElse(object? message) { - if (result is not DeclarativeExecutorResult message) - { - return false; - } - - return string.Equals(Steps.Else(this.Model), message.Result as string, StringComparison.Ordinal); + ExecutorResultMessage executorMessage = ExecutorResultMessage.ThrowIfNot(message); + return string.Equals(Steps.Else(this.Model), executorMessage.Result as string, StringComparison.Ordinal); } #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously @@ -75,8 +68,8 @@ internal sealed class ConditionGroupExecutor : DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + string conversationId = this.State.ExpressionEngine.GetValue(Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}")).Value; + DataValue? inputMessages = this.GetInputMessages(); + + if (inputMessages is not null) + { + foreach (ChatMessage message in inputMessages.ToChatMessages()) + { + await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + } + } + + return default; + } + + private DataValue? GetInputMessages() + { + DataValue? messages = null; + + if (this.Model.Messages is not null) + { + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(this.Model.Messages); + messages = expressionResult.Value; + } + + return messages; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/CreateConversationExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/CreateConversationExecutor.cs new file mode 100644 index 0000000000..b21e2626eb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/CreateConversationExecutor.cs @@ -0,0 +1,21 @@ +// 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; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class CreateConversationExecutor(CreateConversation model, WorkflowAgentProvider agentProvider, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + string conversationId = await agentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false); + await this.AssignAsync(this.Model.ConversationId?.Path, FormulaValue.New(conversationId), context).ConfigureAwait(false); + + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs index 0c09b270fe..8da790bb16 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs @@ -31,7 +31,7 @@ internal sealed class EditTableExecutor(EditTable model, DeclarativeWorkflowStat 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()); + RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), addResult.Value.ToFormula()); await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); await this.AssignAsync(variablePath, newRecord, context).ConfigureAwait(false); break; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs index c18e87dce6..5880211c81 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -30,7 +30,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, DeclarativeWorkflow { 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()); + RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), expressionResult.Value.ToFormula()); await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); await this.AssignAsync(variablePath, newRecord, context).ConfigureAwait(false); } @@ -43,7 +43,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, DeclarativeWorkflow { 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) + if (expressionResult.Value.ToFormula() 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); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs index 8b2dcdc135..28525a9f2f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -48,20 +48,20 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor 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())]; + this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormula())]; } else { - this._values = [expressionResult.Value.ToFormulaValue()]; + this._values = [expressionResult.Value.ToFormula()]; } } - await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); + await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); return default; } - public async ValueTask TakeNextAsync(IWorkflowContext context, CancellationToken cancellationToken) + public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) { if (this.HasValue = this._index < this._values.Length) { @@ -78,7 +78,7 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor } } - public async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) + public async ValueTask ResetAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) { try { @@ -90,7 +90,7 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor } finally { - await this.RaiseCompletionEventAsync(context).ConfigureAwait(false); + await context.RaiseCompletionEventAsync(this.Model).ConfigureAwait(false); } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/GotoExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/GotoExecutor.cs new file mode 100644 index 0000000000..31082fbf5c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/GotoExecutor.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class GotoExecutor(GotoAction model, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) +{ + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + // No action needed - the edge will be followed automatically + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs new file mode 100644 index 0000000000..0db23861ec --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -0,0 +1,138 @@ +// 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.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowAgentProvider agentProvider, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) +{ + private AzureAgentUsage AgentUsage => Throw.IfNull(this.Model.Agent, $"{nameof(this.Model)}.{nameof(this.Model.Agent)}"); + private AzureAgentInput? AgentInput => this.Model.Input; + private AzureAgentOutput? AgentOutput => this.Model.Output; + + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + string? conversationId = this.GetConversationId(); + string agentName = this.GetAgentName(); + string? additionalInstructions = this.GetAdditionalInstructions(); + bool autoSend = this.GetAutoSendValue(); + DataValue? inputMessages = this.GetInputMessages(); + + AgentRunResponse agentResponse = InvokeAgentAsync().ToEnumerable().ToAgentRunResponse(); + + if (autoSend) + { + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false); + } + + ChatMessage response = agentResponse.Messages.Last(); + await this.AssignAsync(this.AgentOutput?.Messages?.Path, response.ToRecord(), context).ConfigureAwait(false); + + return default; + + async IAsyncEnumerable InvokeAgentAsync() + { + AIAgent agent = await agentProvider.GetAgentAsync(agentName, cancellationToken).ConfigureAwait(false); + + ChatClientAgentRunOptions options = + new( + new ChatOptions() + { + Instructions = additionalInstructions, + }); + + AgentThread agentThread = new() { ConversationId = conversationId }; + IAsyncEnumerable agentUpdates = + inputMessages is not null ? + agent.RunStreamingAsync([.. inputMessages.ToChatMessages()], agentThread, options, cancellationToken) : + agent.RunStreamingAsync(agentThread, options, cancellationToken); + + await foreach (AgentRunResponseUpdate update in agentUpdates.ConfigureAwait(false)) + { + await AssignConversationId(((ChatResponseUpdate?)update.RawRepresentation)?.ConversationId).ConfigureAwait(false); + + if (autoSend) + { + await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update)).ConfigureAwait(false); + } + + yield return update; + } + } + + async ValueTask AssignConversationId(string? assignValue) + { + if (assignValue is not null && conversationId is null) + { + conversationId = assignValue; + await this.State.SetConversationIdAsync(context, conversationId).ConfigureAwait(false); + await context.AddEventAsync(new ConversationUpdateEvent(conversationId)).ConfigureAwait(false); + } + } + } + + private DataValue? GetInputMessages() + { + DataValue? userInput = null; + if (this.AgentInput?.Messages is not null) + { + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(this.AgentInput.Messages); + userInput = expressionResult.Value; + } + + return userInput; + } + + private string? GetConversationId() + { + if (this.Model.ConversationId is null) + { + return null; + } + + EvaluationResult conversationIdResult = this.State.ExpressionEngine.GetValue(this.Model.ConversationId); + return conversationIdResult.Value.Length == 0 ? null : conversationIdResult.Value; + } + + private string GetAgentName() => + this.State.ExpressionEngine.GetValue( + Throw.IfNull( + this.AgentUsage.Name, + $"{nameof(this.Model)}.{nameof(this.Model.Agent)}.{nameof(this.Model.Agent.Name)}")).Value; + + private string? GetAdditionalInstructions() + { + string? additionalInstructions = null; + + if (this.AgentInput?.AdditionalInstructions is not null) + { + additionalInstructions = this.State.Format(this.AgentInput.AdditionalInstructions); + } + + return additionalInstructions; + } + + private bool GetAutoSendValue() + { + if (this.AgentOutput?.AutoSend is null) + { + return true; + } + + EvaluationResult autoSendResult = this.State.ExpressionEngine.GetValue(this.AgentOutput.AutoSend); + + return autoSendResult.Value; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs index 377c472a20..80b7c665bc 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -28,7 +28,7 @@ internal sealed class ParseValueExecutor(ParseValue model, DeclarativeWorkflowSt if (expressionResult.Value is RecordDataValue recordValue) { - parsedResult = recordValue.ToFormulaValue(); + parsedResult = recordValue.ToFormula(); } else if (expressionResult.Value is StringDataValue stringValue) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/QuestionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/QuestionExecutor.cs new file mode 100644 index 0000000000..74baaa3ebf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/QuestionExecutor.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Entities; +using Microsoft.Agents.Workflows.Declarative.Events; +using Microsoft.Agents.Workflows.Declarative.Extensions; +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 QuestionExecutor(Question model, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) +{ + public static class Steps + { + public static string Prepare(string id) => $"{id}_{nameof(Prepare)}"; + public static string Input(string id) => $"{id}_{nameof(Input)}"; + public static string Capture(string id) => $"{id}_{nameof(Capture)}"; + } + + private readonly DurableProperty _promptCount = new(nameof(_promptCount)); + private readonly DurableProperty _hasExecuted = new(nameof(_hasExecuted)); + + protected override bool IsDiscreteAction => false; + protected override bool EmitResultEvent => false; + + public bool IsComplete(object? message) + { + ExecutorResultMessage executorMessage = ExecutorResultMessage.ThrowIfNot(message); + return executorMessage.Result is null; + } + + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false); + + InitializablePropertyPath variable = Throw.IfNull(this.Model.Variable); + bool hasValue = this.State.Get(variable.Path) is BlankValue; + bool alwaysPrompt = this.State.ExpressionEngine.GetValue(this.Model.AlwaysPrompt).Value; + + bool proceed = !alwaysPrompt || hasValue; + if (proceed) + { + SkipQuestionMode mode = this.State.ExpressionEngine.GetValue(this.Model.SkipQuestionMode).Value; + proceed = + mode switch + { + SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue => !(await this._hasExecuted.ReadAsync(context).ConfigureAwait(false)), + SkipQuestionMode.AlwaysSkipIfVariableHasValue => hasValue, + SkipQuestionMode.AlwaysAsk => true, + _ => true, + }; + } + + if (proceed) + { + await this.PromptAsync(context, cancellationToken).ConfigureAwait(false); + } + else + { + await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); + } + + return default; + } + + public async ValueTask PrepareResponseAsync(IWorkflowContext context, ExecutorResultMessage message, CancellationToken cancellationToken) + { + int count = await this._promptCount.ReadAsync(context).ConfigureAwait(false); + InputRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt)); + await context.SendMessageAsync(inputRequest).ConfigureAwait(false); + await this._promptCount.WriteAsync(context, count + 1).ConfigureAwait(false); + } + + public async ValueTask CaptureResponseAsync(IWorkflowContext context, InputResponse message, CancellationToken cancellationToken) + { + FormulaValue? extractedValue = null; + if (string.IsNullOrWhiteSpace(message.Value)) + { + string unrecognizedResponse = this.FormatPrompt(this.Model.UnrecognizedPrompt); + await context.AddEventAsync(new MessageActivityEvent(unrecognizedResponse.Trim())).ConfigureAwait(false); + } + else + { + EntityExtractionResult entityResult = EntityExtractor.Parse(this.Model.Entity, message.Value); + if (entityResult.IsValid) + { + extractedValue = entityResult.Value; + } + else + { + string invalidResponse = this.FormatPrompt(this.Model.InvalidPrompt); + await context.AddEventAsync(new MessageActivityEvent(invalidResponse.Trim())).ConfigureAwait(false); + } + } + + if (extractedValue is null) + { + await this.PromptAsync(context, cancellationToken).ConfigureAwait(false); + } + else + { + await this.AssignAsync(this.Model.Variable?.Path, extractedValue, context).ConfigureAwait(false); + await this._hasExecuted.WriteAsync(context, true).ConfigureAwait(false); + await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); + } + } + + public async ValueTask CompleteAsync(IWorkflowContext context, ExecutorResultMessage message, CancellationToken cancellationToken) + { + await context.RaiseCompletionEventAsync(this.Model).ConfigureAwait(false); + } + + private async ValueTask PromptAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + long repeatCount = this.State.ExpressionEngine.GetValue(this.Model.RepeatCount).Value; + int actualCount = await this._promptCount.ReadAsync(context).ConfigureAwait(false); + if (actualCount >= repeatCount) + { + ValueExpression defaultValueExpression = Throw.IfNull(this.Model.DefaultValue); + DataValue defaultValue = this.State.ExpressionEngine.GetValue(defaultValueExpression).Value; + string defaultValueResponse = this.FormatPrompt(this.Model.DefaultValueResponse); + await context.AddEventAsync(new MessageActivityEvent(defaultValueResponse.Trim())).ConfigureAwait(false); + await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); + } + else + { + await context.SendResultMessageAsync(this.Id, result: true, cancellationToken).ConfigureAwait(false); + } + } + + private string FormatPrompt(ActivityTemplateBase? promptTemplate) + { + if (promptTemplate is not MessageActivityTemplate messageActivity) + { + return string.Empty; + } + + return this.State.Format(messageActivity.Text).Trim(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessageExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessageExecutor.cs new file mode 100644 index 0000000000..a686f27f8f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessageExecutor.cs @@ -0,0 +1,27 @@ +// 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.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class RetrieveConversationMessageExecutor(RetrieveConversationMessage model, WorkflowAgentProvider agentProvider, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + string conversationId = this.State.ExpressionEngine.GetValue(Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}")).Value; + string messageId = this.State.ExpressionEngine.GetValue(Throw.IfNull(this.Model.MessageId, $"{nameof(this.Model)}.{nameof(this.Model.MessageId)}")).Value; + + ChatMessage message = await agentProvider.GetMessageAsync(conversationId, messageId, cancellationToken).ConfigureAwait(false); + + await this.AssignAsync(this.Model.Message?.Path, message.ToRecord(), context).ConfigureAwait(false); + + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessagesExecutor.cs new file mode 100644 index 0000000000..c7f3cc8cc8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessagesExecutor.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +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.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class RetrieveConversationMessagesExecutor(RetrieveConversationMessages model, WorkflowAgentProvider agentProvider, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + string conversationId = this.State.ExpressionEngine.GetValue(Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}")).Value; + + ChatMessage[] messages = await agentProvider.GetMessagesAsync( + conversationId, + limit: this.GetLimit(), + after: this.GetMessage(this.Model.MessageAfter), + before: this.GetMessage(this.Model.MessageBefore), + newestFirst: this.IsDescending(), + cancellationToken).ToArrayAsync(cancellationToken).ConfigureAwait(false); + + await this.AssignAsync(this.Model.Messages?.Path, messages.ToTable(), context).ConfigureAwait(false); + + return default; + } + + private int? GetLimit() + { + if (this.Model.Limit is null) + { + return null; + } + + long limit = this.State.ExpressionEngine.GetValue(this.Model.Limit).Value; + return Convert.ToInt32(Math.Min(limit, 100)); + } + + private string? GetMessage(StringExpression? messagExpression) + { + if (messagExpression is null) + { + return null; + } + + return this.State.ExpressionEngine.GetValue(messagExpression).Value; + } + + private bool IsDescending() + { + if (this.Model.SortOrder is null) + { + return false; + } + + AgentMessageSortOrderWrapper sortOrderWrapper = this.State.ExpressionEngine.GetValue(this.Model.SortOrder).Value; + + return sortOrderWrapper.Value == AgentMessageSortOrder.NewestFirst; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs index 2c581668a4..dd950ed070 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs @@ -1,12 +1,9 @@ // 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; @@ -17,17 +14,9 @@ internal sealed class SendActivityExecutor(SendActivity model, DeclarativeWorkfl { 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(); - 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); + await context.AddEventAsync(new MessageActivityEvent(activityText.Trim())).ConfigureAwait(false); } return default; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs new file mode 100644 index 0000000000..9e920dc9a6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs @@ -0,0 +1,39 @@ +// 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; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class SetMultipleVariablesExecutor(SetMultipleVariables model, DeclarativeWorkflowState state) + : DeclarativeActionExecutor(model, state) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + foreach (VariableAssignment assignment in this.Model.Assignments) + { + if (assignment.Variable is null) + { + continue; + } + + if (assignment.Value is null) + { + await this.AssignAsync(assignment.Variable, FormulaValue.NewBlank(), context).ConfigureAwait(false); + } + else + { + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(assignment.Value); + + await this.AssignAsync(assignment.Variable, expressionResult.Value.ToFormula(), 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 index e1c6ec8857..62a678bf28 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs @@ -26,7 +26,7 @@ internal sealed class SetVariableExecutor(SetVariable model, DeclarativeWorkflow { EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(this.Model.Value); - await this.AssignAsync(variablePath, expressionResult.Value.ToFormulaValue(), context).ConfigureAwait(false); + await this.AssignAsync(variablePath, expressionResult.Value.ToFormula(), context).ConfigureAwait(false); } return default; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/Functions/UserMessage.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/Functions/UserMessage.cs new file mode 100644 index 0000000000..fdd8048395 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/Functions/UserMessage.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx.Functions; + +internal sealed class UserMessage : ReflectionFunction +{ + public const string FunctionName = nameof(UserMessage); + + public UserMessage() + : base(FunctionName, FormulaType.String, FormulaType.String) + { } + + public static FormulaValue Execute(StringValue input) => + string.IsNullOrEmpty(input.Value) ? + FormulaValue.NewBlank(RecordType.Empty()) : + FormulaValue.NewRecordFromFields( + new NamedValue(TypeSchema.Message.Fields.Role, FormulaValue.New(ChatRole.User.Value)), + new NamedValue( + TypeSchema.Message.Fields.Content, + FormulaValue.NewTable( + RecordType.Empty() + .Add(TypeSchema.Message.Fields.ContentType, FormulaType.String) + .Add(TypeSchema.Message.Fields.ContentValue, FormulaType.String), + [ + FormulaValue.NewRecordFromFields( + new NamedValue(TypeSchema.Message.Fields.ContentType, FormulaValue.New(TypeSchema.Message.ContentTypes.Text)), + new NamedValue(TypeSchema.Message.Fields.ContentValue, input)) + ] + ) + ) + ); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs index f025164eae..b6e7bff333 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.Workflows.Declarative.PowerFx.Functions; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -36,6 +37,7 @@ internal static class RecalcEngineFactory } config.EnableSetFunction(); + config.AddFunction(new UserMessage()); return config; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs index 83096bcb13..cd0ed99d1b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs @@ -106,12 +106,6 @@ internal static class SystemScope 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); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/TypeSchema.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/TypeSchema.cs new file mode 100644 index 0000000000..21f6770d7b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/TypeSchema.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx.Functions; + +internal static class TypeSchema +{ + public static class Message + { + public static class Fields + { + public const string Id = nameof(Id); + public const string ConversationId = nameof(ConversationId); + public const string AgentId = nameof(AgentId); + public const string RunId = nameof(RunId); + public const string Role = nameof(Role); + public const string Author = nameof(Author); + public const string Text = nameof(Text); + public const string Content = nameof(Content); + public const string ContentType = nameof(ContentType); + public const string ContentValue = nameof(ContentValue); + public const string Metadata = nameof(Metadata); + } + + public static class ContentTypes + { + public const string Text = nameof(AgentMessageContentType.Text); + public const string ImageUrl = nameof(AgentMessageContentType.ImageUrl); + public const string ImageFile = nameof(AgentMessageContentType.ImageFile); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs index 5bf22d92cf..dab7cae1a6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -44,7 +44,7 @@ internal static class WorkflowDiagnostics continue; } - FormulaValue defaultValue = variableDiagnostic.ConstantValue?.ToFormulaValue() ?? variableDiagnostic.Type.NewBlank(); + FormulaValue defaultValue = variableDiagnostic.ConstantValue?.ToFormula() ?? variableDiagnostic.Type.NewBlank(); if (variableDiagnostic.Path.VariableScopeName?.Equals(VariableScopeNames.System, StringComparison.OrdinalIgnoreCase) ?? false) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs index 626811c786..a452ee0643 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs @@ -1,11 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Net.Http; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Azure.AI.Agents.Persistent; -using Azure.Core; -using Azure.Core.Pipeline; +using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; namespace Microsoft.Agents.Workflows.Declarative; @@ -20,47 +18,48 @@ public abstract class WorkflowAgentProvider /// /// 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. + /// The task result contains the associated. 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, 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, clientOptions); - - Interlocked.CompareExchange(ref this._agentsClient, newClient, null); - } - - return this._agentsClient; - } + + /// + /// Asynchronously creates a new conversation and returns its unique identifier. + /// + /// A cancellation token that can be used to cancel the operation. + /// The conversation identifier + public abstract Task CreateConversationAsync(CancellationToken cancellationToken = default); + + /// + /// Creates a new message in the specified conversation. + /// + /// The identifier of the target conversation. + /// The message being added. + /// A cancellation token that can be used to cancel the operation. + public abstract Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default); + + /// + /// Retrieves a specific message from a conversation. + /// + /// The identifier of the target conversation. + /// The identifier of the target message. + /// A cancellation token that can be used to cancel the operation. + /// The requested message + public abstract Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default); + + /// + /// Retrieves a set of messages from a conversation. + /// + /// The identifier of the target conversation. + /// A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 20. + /// A cursor for use in pagination. after is an object ID that defines your place in the list. + /// A cursor for use in pagination. before is an object ID that defines your place in the list. + /// Provide records in descending order when true. + /// A cancellation token that can be used to cancel the operation. + /// The requested messages + public abstract IAsyncEnumerable GetMessagesAsync( + string conversationId, + int? limit = null, + string? after = null, + string? before = null, + bool newestFirst = false, + CancellationToken cancellationToken = default); } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Agents/BasicAgent.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Agents/BasicAgent.yaml new file mode 100644 index 0000000000..2d41b2c5dd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Agents/BasicAgent.yaml @@ -0,0 +1,5 @@ +type: foundry_agent +name: BasicAgent +description: Basic agent for integration tests +model: + id: ${AzureAI:ModelDeployment} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs new file mode 100644 index 0000000000..ad58b2b8d2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Azure.Identity; +using Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Shared.IntegrationTests; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests; + +/// +/// Tests execution of workflow created by . +/// +[Collection("Global")] +public sealed class DeclarativeWorkflowTest(ITestOutputHelper output, AgentFixture agentFixture) : WorkflowTest(output), IClassFixture +{ + [Theory] + [InlineData("SendActivity.yaml", "SendActivity.json")] + [InlineData("InvokeAgent.yaml", "InvokeAgent.json")] + public Task Validate(string workflowFileName, string testcaseFileName) => + this.RunWorkflow(workflowFileName, testcaseFileName); + + private Task RunWorkflow(string workflowFileName, string testcaseFileName) + { + this.Output.WriteLine($"WORKFLOW: {workflowFileName}"); + this.Output.WriteLine($"TESTCASE: {testcaseFileName}"); + + Testcase testcase = ReadTestcase(testcaseFileName); + IConfiguration configuration = InitializeConfig(); + string workflowPath = Path.Combine("Workflows", workflowFileName); + + this.Output.WriteLine($" {testcase.Description}"); + + return + testcase.Setup.Input.Type switch + { + nameof(ChatMessage) => this.RunWorkflow(testcase, workflowPath, configuration), + nameof(String) => this.RunWorkflow(testcase, workflowPath, configuration), + _ => throw new NotSupportedException($"Input type '{testcase.Setup.Input.Type}' is not supported."), + }; + } + + private async Task RunWorkflow( + Testcase testcase, + string workflowPath, + IConfiguration configuration) where TInput : notnull + { + this.Output.WriteLine($"INPUT: {testcase.Setup.Input.Value}"); + + AzureAIConfiguration? foundryConfig = configuration.GetSection("AzureAI").Get(); + Assert.NotNull(foundryConfig); + + IDictionary agentMap = await agentFixture.GetAgentsAsync(foundryConfig); + + IConfiguration workflowConfig = + new ConfigurationBuilder() + .AddInMemoryCollection(agentMap) + .Build(); + + DeclarativeWorkflowOptions workflowOptions = + new(new AzureAgentProvider(foundryConfig.Endpoint, new AzureCliCredential())) + { + Configuration = workflowConfig, + LoggerFactory = this.Output + }; + Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); + + WorkflowEvents workflowEvents = await WorkflowHarness.RunAsync(workflow, (TInput)GetInput(testcase)); + foreach (DeclarativeActionInvokeEvent actionInvokeEvent in workflowEvents.ActionInvokeEvents) + { + this.Output.WriteLine($"ACTION: {actionInvokeEvent.ActionId} [{actionInvokeEvent.ActionType}]"); + } + + Assert.Equal(testcase.Validation.ActionCount, workflowEvents.ActionInvokeEvents.Count); + Assert.Equal(testcase.Validation.ActionCount, workflowEvents.ActionCompleteEvents.Count); + } + + private static object GetInput(Testcase testcase) where TInput : notnull => + testcase.Setup.Input.Type switch + { + nameof(ChatMessage) => new ChatMessage(ChatRole.User, testcase.Setup.Input.Value), + nameof(String) => testcase.Setup.Input.Value, + _ => throw new NotSupportedException($"Input type '{testcase.Setup.Input.Type}' is not supported."), + }; + + private static Testcase ReadTestcase(string testcaseFileName) + { + using Stream testcaseStream = File.Open(Path.Combine("Testcases", testcaseFileName), FileMode.Open); + Testcase? testcase = JsonSerializer.Deserialize(testcaseStream, s_jsonSerializerOptions); + Assert.NotNull(testcase); + return testcase; + } + + private static IConfigurationRoot InitializeConfig() => + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); + + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + WriteIndented = true, + }; +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs new file mode 100644 index 0000000000..f88d4c1f35 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Azure.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.AzureAI; +using Shared.IntegrationTests; + +namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework; + +#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +internal static class AgentFactory +{ + public static async Task> CreateAsync(string agentsDirectory, AzureAIConfiguration config, CancellationToken cancellationToken) + { + PersistentAgentsClient clientAgents = new(config.Endpoint, new AzureCliCredential()); + + IKernelBuilder kernelBuilder = Kernel.CreateBuilder(); + kernelBuilder.Services.AddSingleton(clientAgents); + Kernel kernel = kernelBuilder.Build(); + + AzureAIAgentFactory factory = new(); + + Dictionary agentMap = []; + + foreach (string file in Directory.GetFiles(agentsDirectory, "*.yaml")) + { + Debug.WriteLine($"TEST AGENT: Creating - {file}"); + string agentText = File.ReadAllText(file); + + Agent? agent = await factory.CreateAgentFromYamlAsync(agentText, new AgentCreationOptions() { Kernel = kernel }, configuration: null, cancellationToken); + + Assert.NotNull(agent?.Name); + + Debug.WriteLine($"TEST AGENT: {agent.Name} => {agent.Id}"); + agentMap[agent.Name] = agent.Id; + } + + return agentMap.ToImmutableDictionary(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/AgentFixture.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/AgentFixture.cs new file mode 100644 index 0000000000..fb38bd1279 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/AgentFixture.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Shared.IntegrationTests; + +namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework; + +public sealed class AgentFixture : IDisposable +{ + private static ImmutableDictionary? s_agentMap; + + internal async Task> GetAgentsAsync(AzureAIConfiguration config, CancellationToken cancellationToken = default) + { + s_agentMap ??= await AgentFactory.CreateAsync("Agents", config, cancellationToken); + + return s_agentMap; + } + + public void Dispose() + { + GC.SuppressFinalize(this); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/TestOutputAdapter.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/TestOutputAdapter.cs new file mode 100644 index 0000000000..5887c185ea --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/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.IntegrationTests.Framework; + +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.IntegrationTests/Framework/Testcase.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/Testcase.cs new file mode 100644 index 0000000000..aa35ddf65c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/Testcase.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework; + +public sealed class Testcase +{ + [JsonConstructor] + public Testcase( + string description, + TestcaseSetup setup, + TestcaseValidation validation) + { + this.Description = description; + this.Setup = setup; + this.Validation = validation; + } + + public string Description { get; } + + public TestcaseSetup Setup { get; } + + public TestcaseValidation Validation { get; } +} + +public sealed class TestcaseSetup +{ + [JsonConstructor] + public TestcaseSetup(TestcaseInput input) + { + this.Input = input; + } + public TestcaseInput Input { get; } +} + +public sealed class TestcaseInput +{ + [JsonConstructor] + public TestcaseInput(string type, string value) + { + this.Type = type; + this.Value = value; + } + + public string Type { get; } + public string Value { get; } +} + +public sealed class TestcaseValidation +{ + [JsonConstructor] + public TestcaseValidation(int actionCount) + { + this.ActionCount = actionCount; + } + + public int ActionCount { get; } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowEvents.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowEvents.cs new file mode 100644 index 0000000000..163eb19fdf --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowEvents.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Immutable; +using System.Linq; + +namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework; + +internal sealed class WorkflowEvents +{ + public WorkflowEvents(ImmutableList workflowEvents) + { + this.Events = workflowEvents; + this.EventCounts = workflowEvents.GroupBy(e => e.GetType()).ToImmutableDictionary(e => e.Key, e => e.Count()); + this.ActionInvokeEvents = workflowEvents.OfType().ToImmutableList(); + this.ActionCompleteEvents = workflowEvents.OfType().ToImmutableList(); + } + + public ImmutableList Events { get; } + public IImmutableDictionary EventCounts { get; } + public ImmutableList ActionInvokeEvents { get; } + public ImmutableList ActionCompleteEvents { get; private set; } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs new file mode 100644 index 0000000000..c1b43a76e9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework; + +internal static class WorkflowHarness +{ + public static async Task RunAsync(Workflow workflow, TInput input) where TInput : notnull + { + StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); + ImmutableList workflowEvents = run.WatchStreamAsync().ToEnumerable().ToImmutableList(); + return new WorkflowEvents(workflowEvents); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs new file mode 100644 index 0000000000..afc70f3603 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/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.IntegrationTests.Framework; + +/// +/// Base class for workflow tests. +/// +public abstract class WorkflowTest : IDisposable +{ + public TestOutputAdapter Output { get; } + + protected WorkflowTest(ITestOutputHelper output) + { + this.Output = new TestOutputAdapter(output); + 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.IntegrationTests/Microsoft.Agents.Workflows.Declarative.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Microsoft.Agents.Workflows.Declarative.IntegrationTests.csproj new file mode 100644 index 0000000000..785992170e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Microsoft.Agents.Workflows.Declarative.IntegrationTests.csproj @@ -0,0 +1,37 @@ + + + + $(ProjectsTargetFrameworks) + $(NoWarn);IDE1006 + + + + true + + + + + + + + + + + + + + + + + + Always + + + Always + + + Always + + + + diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Testcases/InvokeAgent.json b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Testcases/InvokeAgent.json new file mode 100644 index 0000000000..a48cc30b79 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Testcases/InvokeAgent.json @@ -0,0 +1,12 @@ +{ + "description": "Produce a single response from an agent.", + "setup": { + "input": { + "type": "String", + "value": "Why is the sky blue?" + } + }, + "validation": { + "action_count": 1 + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Testcases/SendActivity.json b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Testcases/SendActivity.json new file mode 100644 index 0000000000..a0f6b74752 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Testcases/SendActivity.json @@ -0,0 +1,12 @@ +{ + "description": "Send an activity message .", + "setup": { + "input": { + "type": "String", + "value": "Why is the sky blue?" + } + }, + "validation": { + "action_count": 3 + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/AddMessages.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/AddMessages.yaml new file mode 100644 index 0000000000..5a754c9be3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/AddMessages.yaml @@ -0,0 +1,49 @@ +# +# This workflow demonstrates a conversation and message manipulation. +# +# Any Foundry Agent may be used to provide the response. +# See: ./setup/QuestionAgent.yaml +# +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: workflow_test + actions: + + - kind: CreateConversation + id: conversation_create1 + conversationId: Topic.FirstConversationId + + - kind: CreateConversation + id: conversation_create2 + conversationId: Topic.SecondConversationId + + - kind: SendActivity + id: sendActivity_conversation + activity: |- + Conversation 1: {Topic.FirstConversationId} + Conversation 2: {Topic.SecondConversationId} + + - kind: AddConversationMessage + id: add_message + message: Topic.MyMessage1 + role: User + conversationId: =Topic.FirstConversationId + content: + - type: Text + value: {System.LastMessage.Text} + + - kind: SendActivity + id: sendActivity_message + activity: |- + Messsage 1: {Topic.MyMessage1} + + - kind: CopyConversationMessages + id: copy_messages + conversationId: =Topic.SecondConversationId + messages: =[Topic.MyMessage1] + + - kind: SendActivity + id: sendActivity_copy + activity: Done! diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/GetMessage.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/GetMessage.yaml new file mode 100644 index 0000000000..94e8ba4d73 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/GetMessage.yaml @@ -0,0 +1,23 @@ +# +# This workflow demonstrates a conversation and message manipulation. +# +# Any Foundry Agent may be used to provide the response. +# See: ./setup/QuestionAgent.yaml +# +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: workflow_test + actions: + + - kind: RetrieveConversationMessage + id: get_message + message: Topic.MyMessage + conversationId: thread_T8xIzNrNcPkUkoCEGzxg80Vt + messageId: msg_J4x6YZTDUUWNs60FOUAucldy + + - kind: SendActivity + id: sendActivity_message + activity: |- + {Topic.MyMessage} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/GetMessages.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/GetMessages.yaml new file mode 100644 index 0000000000..faaabfb1d1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/GetMessages.yaml @@ -0,0 +1,22 @@ +# +# This workflow demonstrates a conversation and message manipulation. +# +# Any Foundry Agent may be used to provide the response. +# See: ./setup/QuestionAgent.yaml +# +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: workflow_test + actions: + + - kind: RetrieveConversationMessages + id: get_message + messages: Topic.MyMessages + conversationId: thread_T8xIzNrNcPkUkoCEGzxg80Vt + + - kind: SendActivity + id: sendActivity_message + activity: |- + {Topic.MyMessages} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/InvokeAgent.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/InvokeAgent.yaml new file mode 100644 index 0000000000..ad9a2c3d57 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/InvokeAgent.yaml @@ -0,0 +1,15 @@ +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: workflow_test + actions: + + - kind: InvokeAzureAgent + id: invoke_agent + agent: + name: =Env.BasicAgent + input: + messages: =[UserMessage(System.LastMessageText)] + output: + messages: Topic.Answer diff --git a/workflows/HelloWorld.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/SendActivity.yaml similarity index 86% rename from workflows/HelloWorld.yaml rename to dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/SendActivity.yaml index 8fc434ed60..9095fe9655 100644 --- a/workflows/HelloWorld.yaml +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Workflows/SendActivity.yaml @@ -7,14 +7,14 @@ kind: AdaptiveDialog beginDialog: kind: OnActivity - id: workflow_demo + id: workflow_test actions: # Capture input - kind: SetVariable id: setvar_userinput variable: Topic.UserInput - value: =System.LastMessageText + value: =System.LastMessage.Text # Capture environment variable - kind: SetVariable @@ -27,4 +27,4 @@ beginDialog: id: sendActivity_demo activity: |- Hello {Global.UserName}, - You said, "{Topic.UserInput}" \ No newline at end of file + You said, "{Topic.UserInput}" diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index fc7ee92cbf..ddfa6f089e 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -168,7 +168,6 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow [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))] @@ -192,7 +191,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow BeginDialog = new OnActivity.Builder() { - Id = "workflow", + Id = "anything", Actions = [unsupportedAction] } }; @@ -226,7 +225,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow private void AssertMessage(string message) { - Assert.Contains(this.WorkflowEvents.OfType(), e => string.Equals(e.Response.Messages[0].Text.Trim(), message, StringComparison.Ordinal)); + Assert.Contains(this.WorkflowEvents.OfType(), e => string.Equals(e.Message.Trim(), message, StringComparison.Ordinal)); } private Task RunWorkflow(string workflowPath) => this.RunWorkflow(workflowPath, string.Empty); @@ -246,7 +245,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow { if (workflowEvent is ExecutorInvokedEvent invokeEvent) { - DeclarativeExecutorResult? message = invokeEvent.Data as DeclarativeExecutorResult; + ExecutorResultMessage? message = invokeEvent.Data as ExecutorResultMessage; this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]"); } else if (workflowEvent is AgentRunResponseEvent messageEvent) @@ -258,7 +257,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow } private sealed class RootExecutor() : - ReflectingExecutor(WorkflowActionVisitor.RootId("workflow")), + ReflectingExecutor(WorkflowActionVisitor.Steps.Root("anything")), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs index bf450b41a2..a2ac6295dc 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs @@ -19,7 +19,7 @@ public class FormulaValueExtensionsTests BooleanDataValue typedValue = Assert.IsType(dataValue); Assert.Equal(formulaValue.Value, typedValue.Value); - BooleanValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + BooleanValue formulaCopy = Assert.IsType(dataValue.ToFormula()); Assert.Equal(typedValue.Value, formulaCopy.Value); Assert.Equal(bool.TrueString, formulaValue.Format()); @@ -35,7 +35,7 @@ public class FormulaValueExtensionsTests StringDataValue typedValue = Assert.IsType(dataValue); Assert.Equal(formulaValue.Value, typedValue.Value); - StringValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + StringValue formulaCopy = Assert.IsType(typedValue.ToFormula()); Assert.Equal(typedValue.Value, formulaCopy.Value); Assert.Equal(formulaValue.Value, formulaValue.Format()); @@ -51,7 +51,7 @@ public class FormulaValueExtensionsTests NumberDataValue typedValue = Assert.IsType(dataValue); Assert.Equal(formulaValue.Value, typedValue.Value); - DecimalValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + DecimalValue formulaCopy = Assert.IsType(typedValue.ToFormula()); Assert.Equal(typedValue.Value, formulaCopy.Value); Assert.Equal("45.3", formulaValue.Format()); @@ -67,7 +67,7 @@ public class FormulaValueExtensionsTests FloatDataValue typedValue = Assert.IsType(dataValue); Assert.Equal(formulaValue.Value, typedValue.Value); - NumberValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + NumberValue formulaCopy = Assert.IsType(typedValue.ToFormula()); Assert.Equal(typedValue.Value, formulaCopy.Value); Assert.Equal("3.1415926535897", formulaValue.Format()); @@ -103,7 +103,7 @@ public class FormulaValueExtensionsTests DateDataValue typedValue = Assert.IsType(dataValue); Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), typedValue.Value); - DateValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + DateValue formulaCopy = Assert.IsType(dataValue.ToFormula()); Assert.Equal(typedValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); Assert.Equal($"{timestamp}", formulaValue.Format()); @@ -120,7 +120,7 @@ public class FormulaValueExtensionsTests DateTimeDataValue typedValue = Assert.IsType(dataValue); Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), typedValue.Value); - DateTimeValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + DateTimeValue formulaCopy = Assert.IsType(typedValue.ToFormula()); Assert.Equal(typedValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); Assert.Equal($"{timestamp}", formulaValue.Format()); @@ -136,7 +136,7 @@ public class FormulaValueExtensionsTests TimeDataValue typedValue = Assert.IsType(dataValue); Assert.Equal(formulaValue.Value, typedValue.Value); - TimeValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + TimeValue formulaCopy = Assert.IsType(typedValue.ToFormula()); Assert.Equal(typedValue.Value, formulaCopy.Value); Assert.Equal("10:35:00", formulaValue.Format()); @@ -158,7 +158,7 @@ public class FormulaValueExtensionsTests Assert.Contains(property.Key, formulaValue.Fields.Select(field => field.Name)); } - RecordValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue(), exactMatch: false); + RecordValue formulaCopy = Assert.IsType(dataValue.ToFormula(), exactMatch: false); Assert.Equal(formulaCopy.Fields.Count(), dataValue.Properties.Count); foreach (NamedValue field in formulaCopy.Fields) { @@ -174,6 +174,16 @@ public class FormulaValueExtensionsTests } """, formulaValue.Format().Replace(Environment.NewLine, "\n")); + + Dictionary source = + new() + { + ["FieldA"] = 1, + ["FieldB"] = 2, + ["FieldC"] = 3 + }; + FormulaValue formula = source.ToFormula(); + Assert.IsType(formula, exactMatch: false); } [Fact] @@ -188,7 +198,7 @@ public class FormulaValueExtensionsTests TableDataValue dataValue = formulaValue.ToTable(); Assert.Equal(formulaValue.Rows.Count(), dataValue.Values.Length); - TableValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue(), exactMatch: false); + TableValue formulaCopy = Assert.IsType(dataValue.ToFormula(), exactMatch: false); Assert.Equal(formulaCopy.Rows.Count(), dataValue.Values.Length); Assert.Equal( diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs index 4337ef2952..5ef54a47a3 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs @@ -27,7 +27,7 @@ public sealed class SendActivityExecutorTest(ITestOutputHelper output) : Workflo // Assert this.VerifyModel(model, action); - Assert.Contains(events, e => e is AgentRunResponseEvent); + Assert.Contains(events, e => e is MessageActivityEvent); } private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs index 8d7393864b..b2149c1c92 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs @@ -14,7 +14,7 @@ using Xunit.Abstractions; namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; /// -/// Base test class for implementations. +/// Base test class for implementations. /// public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : WorkflowTest(output) { @@ -26,7 +26,7 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor protected string FormatDisplayName(string name) => $"{this.GetType().Name}_{name}"; - internal async Task Execute(WorkflowActionExecutor executor) + internal async Task Execute(DeclarativeActionExecutor executor) { TestWorkflowExecutor workflowExecutor = new(); WorkflowBuilder workflowBuilder = new(workflowExecutor); @@ -38,7 +38,7 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor return events; } - internal void VerifyModel(DialogAction model, WorkflowActionExecutor action) + internal void VerifyModel(DialogAction model, DeclarativeActionExecutor action) { Assert.Equal(model.Id, action.Id); Assert.Equal(model, action.Model); @@ -80,7 +80,7 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor { public async ValueTask HandleAsync(WorkflowScopes message, IWorkflowContext context) { - await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id)).ConfigureAwait(false); + await context.SendMessageAsync(new ExecutorResultMessage(this.Id)).ConfigureAwait(false); } } } diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index d9482d5f6f..9612cc8464 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -81,54 +81,64 @@ beginDialog: id: sendActivity_yFsbRy activity: Analyzing facts... - - kind: AnswerQuestionWithAI + - kind: CreateConversation + id: conversation_1a2b3c + conversationId: Topic.InternalConversationId + + - kind: InvokeAzureAgent 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. + conversationId: =Topic.InternalConversationId + agent: + name: =Env.FOUNDRY_AGENT_RESEARCHANALYST + output: + messages: Topic.TaskFacts + input: + messages: =[UserMessage(Topic.InputTask)] + additionalInstructions: |- + 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: + 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. + 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: + 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 + 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. + 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 + - kind: InvokeAzureAgent 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. + conversationId: =Topic.InternalConversationId + agent: + name: =Env.FOUNDRY_AGENT_RESEARCHMANAGER + output: + messages: Topic.Plan + input: + messages: =[UserMessage(Topic.InputTask)] + additionalInstructions: |- + 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]" + Only select the following team which is listed as "- [Name]: [Description]" - {Topic.TeamDescription} + {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]" + 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. + 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 @@ -162,54 +172,57 @@ beginDialog: id: sendActivity_bwNZiM activity: {Topic.TaskInstructions} - - kind: AnswerQuestionWithAI + - kind: InvokeAzureAgent 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: + conversationId: =Topic.InternalConversationId + agent: + name: =Env.FOUNDRY_AGENT_RESEARCHMANAGER + output: + messages: Topic.ProgressLedgerUpdate + input: + messages: =[UserMessage(Topic.AgentResponseText)] + additionalInstructions: |- + Recall we are working on the following request: - {Topic.InputTask} + {Topic.InputTask} - And we have assembled the following team: + And we have assembled the following team: - {Topic.TeamDescription} + {Topic.TeamDescription} - To make progress on the request, please answer the following questions, including necessary reasoning: + 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) + - 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: + 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 - }} - }} + {{ + "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 @@ -266,16 +279,20 @@ beginDialog: id: sendActivity_kdl3mC activity: Completed! {Topic.TypedProgressLedger.is_request_satisfied.reason} - - kind: AnswerQuestionWithAI + - kind: InvokeAzureAgent 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. + conversationId: =System.ConversationId + agent: + name: =Env.FOUNDRY_AGENT_RESEARCHMANAGER + output: + messages: Topic.FinalResponse + input: + messages: =[UserMessage(Topic.SeedTask)] + additionalInstructions: |- + 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 @@ -342,45 +359,53 @@ beginDialog: id: sendActivity_cwNZiM activity: Re-analyzing facts... - - kind: AnswerQuestionWithAI + - kind: InvokeAzureAgent 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: + conversationId: =Topic.InternalConversationId + agent: + name: =Env.FOUNDRY_AGENT_RESEARCHANALYST + output: + messages: Topic.TaskFacts + input: + messages: |- + =[ + UserMessage( + "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. + " & Topic.InputTask) + ] + additionalInstructions: |- + 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: + Here is the old fact sheet: - {Topic.TaskFacts} + {Topic.TaskFacts} - kind: SendActivity id: sendActivity_dsBaJU activity: Re-analyzing plan... - - - kind: AnswerQuestionWithAI + + - kind: InvokeAzureAgent 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): + conversationId: =Topic.InternalConversationId + agent: + name: =Env.FOUNDRY_AGENT_RESEARCHMANAGER + output: + messages: Topic.Plan + input: + additionalInstructions: |- + 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} + {Topic.TeamDescription} - kind: SetVariable id: setVariable_jW7tmM @@ -452,13 +477,18 @@ beginDialog: displayName: If next Agent tool Exists actions: - - kind: AnswerQuestionWithAI + - kind: InvokeAzureAgent id: question_orsBf06 - variable: Topic.AgentResponse - userInput: =Topic.SeedTask - additionalInstructions: |- - {First(Topic.NextSpeaker).agentid}, - {Topic.TypedProgressLedger.instruction_or_question.answer} + displayName: Progress Ledger Prompt + conversationId: =System.ConversationId + agent: + name: =First(Topic.NextSpeaker).agentid + output: + messages: Topic.AgentResponse + input: + messages: =[UserMessage(Topic.SeedTask)] + additionalInstructions: |- + {Topic.TypedProgressLedger.instruction_or_question.answer} - kind: SetVariable id: setVariable_XzNrdM diff --git a/workflows/HumanInLoop.yaml b/workflows/HumanInLoop.yaml new file mode 100644 index 0000000000..997dc558df --- /dev/null +++ b/workflows/HumanInLoop.yaml @@ -0,0 +1,44 @@ +# +# 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: + + # Capture original input + - kind: SetVariable + id: set_project + variable: Topic.OriginalInput + value: =System.LastMessage.Text + + # Request input from user + - kind: Question + id: question_confirm + alwaysPrompt: false + property: Topic.ConfirmedInput + prompt: + kind: Message + text: + - "CONFIRM:" + entity: + kind: StringPrebuiltEntity + + # Respond with input + - kind: SendActivity + id: sendActivity_input + activity: |- + You entered: + {Topic.OriginalInput} + + Confirmed input: + {Topic.ConfirmedInput} + + + + diff --git a/workflows/Marketing.yaml b/workflows/Marketing.yaml new file mode 100644 index 0000000000..6293a3b616 --- /dev/null +++ b/workflows/Marketing.yaml @@ -0,0 +1,46 @@ +# +# 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: + + - kind: InvokeAzureAgent + id: invoke_analyst + conversationId: =System.ConversationId + agent: + name: =Env.FOUNDRY_AGENT_ANSWER + input: + messages: =[UserMessage(System.LastMessageText)] + additionalInstructions: |- + You are a marketing analyst. Given a product description, identify: + - Key features + - Target audience + - Unique selling points + + - kind: InvokeAzureAgent + id: invoke_writer + conversationId: =System.ConversationId + agent: + name: =Env.FOUNDRY_AGENT_ANSWER + input: + additionalInstructions: |- + You are a marketing copywriter. Given a block of text describing features, audience, and USPs, + compose a compelling marketing copy (like a newsletter section) that highlights these points. + Output should be short (around 150 words), output just the copy as a single text block. + + - kind: InvokeAzureAgent + id: invoke_editor + conversationId: =System.ConversationId + agent: + name: =Env.FOUNDRY_AGENT_ANSWER + input: + additionalInstructions: |- + You are an editor. Given the draft copy, correct grammar, improve clarity, ensure consistent tone, + give format and make it polished. Output the final improved copy as a single text block. \ No newline at end of file diff --git a/workflows/MathChat.yaml b/workflows/MathChat.yaml index 117a241d02..3ddb611e21 100644 --- a/workflows/MathChat.yaml +++ b/workflows/MathChat.yaml @@ -34,21 +34,27 @@ beginDialog: - kind: SetVariable id: set_project variable: Topic.Project - value: =System.LastMessage.Text + value: =System.LastMessageText - - kind: AnswerQuestionWithAI + - kind: InvokeAzureAgent id: question_student - userInput: =Topic.Project - additionalInstructions: {Env.FOUNDRY_AGENT_STUDENT} + conversationId: =System.ConversationId + agent: + name: =Env.FOUNDRY_AGENT_STUDENT + input: + messages: =[UserMessage(Topic.Project)] + output: + messages: Topic.Answer - kind: ResetVariable id: reset_project variable: Topic.Project - - kind: AnswerQuestionWithAI + - kind: InvokeAzureAgent id: question_teacher - userInput: ="" - additionalInstructions: {Env.FOUNDRY_AGENT_TEACHER} + conversationId: =System.ConversationId + agent: + name: =Env.FOUNDRY_AGENT_TEACHER - kind: SetVariable id: set_count_increment @@ -59,7 +65,7 @@ beginDialog: id: check_completion conditions: - - condition: =!IsBlank(Find("congratulations", Lower(System.LastMessage.Text))) + - condition: =!IsBlank(Find("congratulations", Lower(System.LastMessageText))) id: check_turn_done actions: diff --git a/workflows/Question.yaml b/workflows/Question.yaml deleted file mode 100644 index 22a9d7b6d4..0000000000 --- a/workflows/Question.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# -# 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}