.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>
This commit is contained in:
Chris
2025-09-15 13:41:07 -07:00
committed by GitHub
Unverified
parent db58a10a37
commit 74879489a4
79 changed files with 2830 additions and 685 deletions
+6 -4
View File
@@ -86,14 +86,16 @@
<!-- Identity -->
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.74.1" />
<!-- Workflows -->
<PackageVersion Include="Microsoft.Bot.ObjectModel" Version="2025.8.2-1" />
<PackageVersion Include="Microsoft.Bot.ObjectModel.Json" Version="2025.8.2-1" />
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="2025.8.2-1" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.4.0-build.20250625-1002" />
<PackageVersion Include="Microsoft.Bot.ObjectModel" Version="1.2025.915-1" />
<PackageVersion Include="Microsoft.Bot.ObjectModel.Json" Version="1.2025.915-1" />
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.915-1" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.4.0-build.20250821-1001" />
<!-- Test -->
<PackageVersion Include="FluentAssertions" Version="8.6.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Moq" Version="[4.18.4]" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Abstractions" Version="1.65.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Yaml" Version="1.65.0-beta" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.abstractions" Version="2.0.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" />
+4 -3
View File
@@ -78,9 +78,9 @@
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/Declarative/Examples/">
<File Path="../workflows/DeepResearch.yaml" />
<File Path="../workflows/HelloWorld.yaml" />
<File Path="../workflows/HumanInLoop.yaml" />
<File Path="../workflows/Marketing.yaml" />
<File Path="../workflows/MathChat.yaml" />
<File Path="../workflows/Question.yaml" />
<File Path="../workflows/README.md" />
<File Path="../workflows/wttr.json" />
</Folder>
@@ -96,8 +96,8 @@
<Project Path="samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/Checkpoint/">
<Project Path="samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/CheckpointAndResume.csproj" />
<Project Path="samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/CheckpointAndRehydrate.csproj" />
<Project Path="samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/CheckpointAndResume.csproj" />
<Project Path="samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/CheckpointWithHumanInTheLoop.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/HumanInTheLoop/">
@@ -265,6 +265,7 @@
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Microsoft.Agents.Workflows.Declarative.IntegrationTests.csproj" />
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
<Project Path="tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj" />
@@ -7,7 +7,6 @@
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
<NoWarn>$(NoWarn);CA1812</NoWarn>
</PropertyGroup>
@@ -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<string> workflow = DeclarativeWorkflowBuilder.Build<string>(this.WorkflowFile, options);
Workflow<string> 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<StreamingRun> 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<string> 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<string> workflow = DeclarativeWorkflowBuilder.Build<string>(this.WorkflowFile, options);
return workflow;
}
private const string DefaultWorkflow = "HelloWorld.yaml";
private const string ConfigKeyFoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT";
private static readonly Dictionary<string, string> s_nameCache = [];
private static readonly HashSet<string> s_fileCache = [];
private static Dictionary<string, string> NameCache { get; } = [];
private static HashSet<string> 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<ExternalRequest?> MonitorWorkflowRunAsync(Checkpointed<StreamingRun> 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<InputResponse>(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)
@@ -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;
/// <summary>
/// Provides functionality to interact with Foundry agents within a specified project context.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="projectEndpoint">The endpoint URL of the Foundry project. This must be a valid, non-null URI pointing to the project.</param>
/// <param name="projectCredentials">The credentials used to authenticate with the Foundry project. This must be a valid instance of <see cref="TokenCredential"/>.</param>
/// <param name="httpClient">An optional <see cref="HttpClient"/> instance to be used for making HTTP requests. If not provided, a default client will be used.</param>
public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential projectCredentials, HttpClient? httpClient = null) : WorkflowAgentProvider
{
private static readonly Dictionary<string, MessageRole> 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;
/// <inheritdoc/>
public override async Task<string> CreateConversationAsync(CancellationToken cancellationToken = default)
{
PersistentAgentThread conversation = await this.GetAgentsClient().Threads.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
return conversation.Id;
}
/// <inheritdoc/>
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<string, string>? 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<MessageInputContentBlock> 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;
// }
// }
//}
}
/// <inheritdoc/>
public override async Task<AIAgent> GetAgentAsync(string agentId, CancellationToken cancellationToken = default)
{
AIAgent agent = await this.GetAgentsClient().GetAIAgentAsync(agentId, chatOptions: null, cancellationToken).ConfigureAwait(false);
return agent;
}
/// <inheritdoc/>
public override async Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default)
{
PersistentThreadMessage message = await this.GetAgentsClient().Messages.GetMessageAsync(conversationId, messageId, cancellationToken).ConfigureAwait(false);
return ToChatMessage(message);
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatMessage> 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<AIContent> 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<string, object?>(m.Key, m.Value)));
}
}
}
@@ -34,6 +34,7 @@ public static class DeclarativeWorkflowBuilder
using StreamReader yamlReader = File.OpenText(workflowFile);
return Build<TInput>(yamlReader, options, inputTransform);
}
/// <summary>
/// Builds a process from the provided YAML definition of a CPS Topic ObjectModel.
/// </summary>
@@ -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);
@@ -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;
/// <summary>
/// Event that broadcasts the conversation identifier.
/// </summary>
public class ConversationUpdateEvent(string executorid, string conversationId) : ExecutorEvent(executorid, conversationId)
{
/// <summary>
/// The conversation ID associated with the workflow.
/// </summary>
public string ConversationId { get; } = conversationId;
}
/// <summary>
/// Event that indicates a declarative action has been invoked.
/// </summary>
public class DeclarativeActionInvokeEvent(string actionId, DialogAction action, string? priorActionId) : WorkflowEvent(action)
{
/// <summary>
/// The declarative action id.
/// </summary>
public string ActionId => actionId;
/// <summary>
/// The declarative action type name.
/// </summary>
public string ActionType => action.GetType().Name;
/// <summary>
/// Identifier of the parent action.
/// </summary>
public string? ParentActionId => action.GetParentId();
/// <summary>
/// Identifier of the previous action.
/// </summary>
public string? PriorActionId => priorActionId;
}
/// <summary>
/// Event that indicates a declarative action has completed.
/// </summary>
public class DeclarativeActionCompleteEvent(string actionId, DialogAction action) : WorkflowEvent(action)
{
/// <summary>
/// The declarative action identifier.
/// </summary>
public string ActionId => actionId;
/// <summary>
/// The declarative action type name.
/// </summary>
public string ActionType => action.GetType().Name;
/// <summary>
/// Identifier of the parent action.
/// </summary>
public string? ParentActionId => action.GetParentId();
}
@@ -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;
}
@@ -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 = @"(?<value>[-+]?(?:\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}");
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.Workflows.Declarative;
/// <summary>
/// Event that broadcasts the conversation identifier.
/// </summary>
public sealed class ConversationUpdateEvent : WorkflowEvent
{
/// <summary>
/// The conversation ID associated with the workflow.
/// </summary>
public string ConversationId { get; }
internal ConversationUpdateEvent(string conversationId)
: base(conversationId)
{
this.ConversationId = conversationId;
}
}
@@ -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;
/// <summary>
/// Event that indicates a declarative action has been invoked.
/// </summary>
public sealed class DeclarativeActionInvokeEvent : WorkflowEvent
{
/// <summary>
/// The declarative action id.
/// </summary>
public string ActionId { get; }
/// <summary>
/// The declarative action type name.
/// </summary>
public string ActionType { get; }
/// <summary>
/// Identifier of the parent action.
/// </summary>
public string? ParentActionId { get; }
/// <summary>
/// Identifier of the previous action.
/// </summary>
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;
}
}
@@ -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;
/// <summary>
/// Event that indicates a declarative action has completed.
/// </summary>
public sealed class DeclarativeActionCompleteEvent : WorkflowEvent
{
/// <summary>
/// The declarative action identifier.
/// </summary>
public string ActionId { get; }
/// <summary>
/// The declarative action type name.
/// </summary>
public string ActionType { get; }
/// <summary>
/// Identifier of the parent action.
/// </summary>
public string? ParentActionId { get; }
internal DeclarativeActionCompleteEvent(DialogAction action) : base(action)
{
this.ActionId = action.GetId();
this.ActionType = action.GetType().Name;
this.ParentActionId = action.GetParentId();
}
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.Workflows.Declarative.Events;
/// <summary>
/// Represents a request for user input.
/// </summary>
public sealed class InputRequest
{
/// <summary>
/// The prompt message to display to the user.
/// </summary>
public string Prompt { get; }
internal InputRequest(string prompt)
{
this.Prompt = prompt;
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.Workflows.Declarative.Events;
/// <summary>
/// Represents a user input response.
/// </summary>
public sealed class InputResponse
{
/// <summary>
/// The response value.
/// </summary>
public string Value { get; }
/// <summary>
/// Initializes a new instance of the <see cref="InputResponse"/> class.
/// </summary>
/// <param name="value">The response value.</param>
public InputResponse(string value)
{
this.Value = value;
}
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.Workflows.Declarative;
/// <summary>
/// Event that broadcasts the conversation identifier.
/// </summary>
public sealed class MessageActivityEvent : WorkflowEvent
{
/// <summary>
/// The conversation ID associated with the workflow.
/// </summary>
public string Message { get; }
internal MessageActivityEvent(string message) : base(message)
{
this.Message = message;
}
}
@@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Declarative;
/// <summary>
/// Represents an exception that occurs when the declarative model is not supported.
/// </summary>
public class DeclarativeModelException : DeclarativeWorkflowException
public sealed class DeclarativeModelException : DeclarativeWorkflowException
{
/// <summary>
/// Initializes a new instance of the <see cref="DeclarativeModelException"/> class.
@@ -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<ChatMessage> messages) =>
FormulaValue.NewTable(s_messageRecordType, messages.Select(message => message.ToRecord()));
public static IEnumerable<ChatMessage> 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<ChatMessage> 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<RecordDataValue>("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<string, DataValue> 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<StringDataValue>(TypeSchema.Message.Fields.Role);
if (roleValue is null || string.IsNullOrWhiteSpace(roleValue.Value))
{
return ChatRole.User;
}
AgentMessageRole? role = null;
if (Enum.TryParse<AgentMessageRole>(roleValue.Value, out AgentMessageRole parsedRole))
{
role = parsedRole;
}
return role.ToChatRole();
}
private static IEnumerable<AIContent> GetContent(this RecordDataValue message)
{
TableDataValue? content = message.GetProperty<TableDataValue>(TypeSchema.Message.Fields.Content);
if (content is not null)
{
foreach (RecordDataValue contentItem in content.Values)
{
StringDataValue? contentValue = contentItem?.GetProperty<StringDataValue>(TypeSchema.Message.Fields.ContentValue);
if (contentValue is null || string.IsNullOrWhiteSpace(contentValue.Value))
{
continue;
}
yield return
contentItem?.GetProperty<StringDataValue>(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<TValue>(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<NamedValue> 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<RecordValue> GetContentRecords(this ChatMessage message) =>
message.Contents.Select(content => RecordValue.NewRecordFromFields(content.GetContentFields()));
private static IEnumerable<NamedValue> 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<NamedValue> 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<NamedValue> 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());
}
@@ -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<string, object?> ToDictionary(this RecordDataValue record)
{
Dictionary<string, object?> result = [];
foreach (KeyValuePair<string, DataValue> property in record.Properties)
{
result[property.Key] = property.Value.ToObject();
}
return result;
}
}
@@ -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<NamedValue> 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<RecordValue> records = [.. value.OfType<ExpandoObject>().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<ExpandoObject>().Select(element => element.ToRecord())]);
private static KeyValuePair<string, DataValue> GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.ToDataValue());
@@ -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));
}
@@ -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);
}
@@ -10,21 +10,21 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions;
internal static class TemplateExtensions
{
public static string? Format(this RecalcEngine engine, IEnumerable<TemplateLine> template)
public static string Format(this RecalcEngine engine, IEnumerable<TemplateLine> 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)
@@ -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>(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<DeclarativeExecutorResult>
internal abstract class DeclarativeActionExecutor : Executor<ExecutorResultMessage>
{
public const string RootActionId = "(root)";
private static readonly ImmutableHashSet<string> s_mutableScopes =
new HashSet<string>
{
@@ -37,7 +33,7 @@ internal abstract class WorkflowActionExecutor : Executor<DeclarativeExecutorRes
private string? _parentId;
protected WorkflowActionExecutor(DialogAction model, DeclarativeWorkflowState state)
protected DeclarativeActionExecutor(DialogAction model, DeclarativeWorkflowState state)
: base(model.Id.Value)
{
if (!model.HasRequiredProperties)
@@ -51,16 +47,18 @@ internal abstract class WorkflowActionExecutor : Executor<DeclarativeExecutorRes
public DialogAction Model { get; }
public string ParentId => this._parentId ??= this.Model.GetParentId() ?? RootActionId;
public string ParentId => this._parentId ??= this.Model.GetParentId() ?? WorkflowActionVisitor.Steps.Root();
internal ILogger Logger { get; set; } = NullLogger<WorkflowActionExecutor>.Instance;
internal ILogger Logger { get; set; } = NullLogger<DeclarativeActionExecutor>.Instance;
protected DeclarativeWorkflowState State { get; }
protected virtual bool IsDiscreteAction => true;
protected virtual bool EmitResultEvent => true;
/// <inheritdoc/>
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<DeclarativeExecutorRes
return;
}
await this.RaiseInvocationEventAsync(context, message.ExecutorId).ConfigureAwait(false);
await context.RaiseInvocationEventAsync(this.Model, message.ExecutorId).ConfigureAwait(false);
await this.State.RestoreAsync(context, default).ConfigureAwait(false);
Debug.WriteLine($"RESULT #{this.Id} - {message.Result ?? "(null)"}");
try
{
object? result = await this.ExecuteAsync(context, cancellationToken: default).ConfigureAwait(false);
await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id, result)).ConfigureAwait(false);
if (this.EmitResultEvent)
{
await context.SendResultMessageAsync(this.Id, result).ConfigureAwait(false);
}
}
catch (DeclarativeActionException exception)
{
@@ -92,15 +93,27 @@ internal abstract class WorkflowActionExecutor : Executor<DeclarativeExecutorRes
{
if (this.IsDiscreteAction)
{
await this.RaiseCompletionEventAsync(context).ConfigureAwait(false);
await context.RaiseCompletionEventAsync(this.Model).ConfigureAwait(false);
}
}
}
protected abstract ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default);
protected async ValueTask AssignAsync(PropertyPath targetPath, FormulaValue result, IWorkflowContext context)
/// <summary>
/// Restore the state of the executor from a checkpoint.
/// This must be overridden to restore any state that was saved during checkpointing.
/// </summary>
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<DeclarativeExecutorRes
string message = $"Unexpected workflow failure during {this.Model.GetType().Name} [{this.Id}]: {text}";
return exception is null ? new(message) : new(message, exception);
}
protected ValueTask RaiseInvocationEventAsync(IWorkflowContext context, string? priorEventId = null) => context.AddEventAsync(new DeclarativeActionInvokeEvent(this.Id, this.Model, priorEventId));
protected ValueTask RaiseCompletionEventAsync(IWorkflowContext context) => context.AddEventAsync(new DeclarativeActionCompleteEvent(this.Id, this.Model));
}
@@ -22,6 +22,6 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
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);
}
}
@@ -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;
@@ -77,9 +77,9 @@ internal sealed class DeclarativeWorkflowState
await context.QueueStateUpdateAsync(varName, value.ToObject(), scopeName).ConfigureAwait(false);
}
public string? Format(IEnumerable<TemplateLine> template) => this._engine.Format(template);
public string Format(IEnumerable<TemplateLine> 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<object>(key, scopeName).ConfigureAwait(false);
this._scopes.Set(key, value.ToFormulaValue(), scopeName);
this._scopes.Set(key, value.ToFormula(), scopeName);
}
}
}
@@ -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<TMessage>(IWorkflowContext context, TMessage message, CancellationToken cancellationToken) where TMessage : notnull;
internal sealed class DelegateActionExecutor : Executor<DeclarativeExecutorResult>
internal sealed class DelegateActionExecutor(string actionId, DelegateAction<ExecutorResultMessage>? action = null, bool emitResult = true)
: DelegateActionExecutor<ExecutorResultMessage>(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<TMessage> : Executor<TMessage> where TMessage : notnull
{
private readonly DelegateAction<TMessage>? _action;
private readonly bool _emitResult;
public DelegateActionExecutor(string actionId, DelegateAction<TMessage>? 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);
}
}
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
namespace Microsoft.Agents.Workflows.Declarative.Interpreter;
internal sealed class DurableProperty<TValue>(string name) where TValue : struct
{
public async ValueTask<TValue> ReadAsync(IWorkflowContext context)
{
TValue? storedValue = await context.ReadStateAsync<TValue>(name).ConfigureAwait(false);
return storedValue ?? default;
}
public ValueTask WriteAsync(IWorkflowContext context, TValue value) =>
context.QueueStateUpdateAsync(name, value);
}
@@ -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;
}
}
@@ -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<InputRequest, InputResponse>(inputId);
this._workflowModel.AddPort(inputPort, parentId);
this._workflowModel.AddLinkFromPeer(parentId, inputId);
string captureId = QuestionExecutor.Steps.Capture(actionId);
this.ContinueWith(new DelegateActionExecutor<InputResponse>(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<object?, bool>? 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<ExecutorResultMessage>? 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<ExecutorResultMessage>? 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)}");
}
@@ -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<AddConversationMessage>(model, state)
{
protected override async ValueTask<object?> 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<AIContent> 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();
}
}
@@ -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<AnswerQuestionWithAI>(model, state)
{
protected override async ValueTask<object?> 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<string> 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<AgentRunResponseUpdate> agentUpdates =
!string.IsNullOrWhiteSpace(userInput) ?
agent.RunStreamingAsync(userInput, agentThread, options, cancellationToken) :
agent.RunStreamingAsync(agentThread, options, cancellationToken);
string? messageId = null;
List<AgentRunResponseUpdate> 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);
}
}
}
}
@@ -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<Conditi
protected override bool IsDiscreteAction => 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<Conditi
return Steps.Else(this.Model);
}
public async ValueTask DoneAsync(IWorkflowContext context, CancellationToken cancellationToken)
public async ValueTask DoneAsync(IWorkflowContext context, ExecutorResultMessage _, CancellationToken cancellationToken)
{
await this.RaiseCompletionEventAsync(context).ConfigureAwait(false);
await context.RaiseCompletionEventAsync(this.Model).ConfigureAwait(false);
}
}
@@ -0,0 +1,45 @@
// 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.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Workflows.Declarative.ObjectModel;
internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages model, WorkflowAgentProvider agentProvider, DeclarativeWorkflowState state) :
DeclarativeActionExecutor<CopyConversationMessages>(model, state)
{
protected override async ValueTask<object?> 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<DataValue> expressionResult = this.State.ExpressionEngine.GetValue(this.Model.Messages);
messages = expressionResult.Value;
}
return messages;
}
}
@@ -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<CreateConversation>(model, state)
{
protected override async ValueTask<object?> 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;
}
}
@@ -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<DataValue> 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;
@@ -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<DataValue> 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<DataValue> 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);
@@ -48,20 +48,20 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
EvaluationResult<DataValue> 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<Foreach>
}
}
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<Foreach>
}
finally
{
await this.RaiseCompletionEventAsync(context).ConfigureAwait(false);
await context.RaiseCompletionEventAsync(this.Model).ConfigureAwait(false);
}
}
}
@@ -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<GotoAction>(model, state)
{
protected override ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
// No action needed - the edge will be followed automatically
return default;
}
}
@@ -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<InvokeAzureAgent>(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<object?> 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<AgentRunResponseUpdate> InvokeAgentAsync()
{
AIAgent agent = await agentProvider.GetAgentAsync(agentName, cancellationToken).ConfigureAwait(false);
ChatClientAgentRunOptions options =
new(
new ChatOptions()
{
Instructions = additionalInstructions,
});
AgentThread agentThread = new() { ConversationId = conversationId };
IAsyncEnumerable<AgentRunResponseUpdate> 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<DataValue> 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<string> 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<bool> autoSendResult = this.State.ExpressionEngine.GetValue(this.AgentOutput.AutoSend);
return autoSendResult.Value;
}
}
@@ -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)
{
@@ -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<Question>(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<int> _promptCount = new(nameof(_promptCount));
private readonly DurableProperty<bool> _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<object?> 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();
}
}
@@ -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<RetrieveConversationMessage>(model, state)
{
protected override async ValueTask<object?> 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;
}
}
@@ -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<RetrieveConversationMessages>(model, state)
{
protected override async ValueTask<object?> 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;
}
}
@@ -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;
@@ -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<SetMultipleVariables>(model, state)
{
protected override async ValueTask<object?> 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<DataValue> expressionResult = this.State.ExpressionEngine.GetValue(assignment.Value);
await this.AssignAsync(assignment.Variable, expressionResult.Value.ToFormula(), context).ConfigureAwait(false);
}
}
return default;
}
}
@@ -26,7 +26,7 @@ internal sealed class SetVariableExecutor(SetVariable model, DeclarativeWorkflow
{
EvaluationResult<DataValue> 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;
@@ -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))
]
)
)
);
}
@@ -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;
}
@@ -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);
@@ -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);
}
}
}
@@ -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)
{
@@ -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
/// </summary>
/// <param name="agentId">The unique identifier of the AI agent to retrieve. Cannot be null or empty.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="AIAgent"/> associated
/// with the specified <paramref name="agentId"/>. Returns <see langword="null"/> if no agent is found.</returns>
/// <returns>The task result contains the <see cref="AIAgent"/> associated.</returns>
public abstract Task<AIAgent> GetAgentAsync(string agentId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Provides functionality to interact with Foundry agents within a specified project context.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="projectEndpoint">The endpoint URL of the Foundry project. This must be a valid, non-null URI pointing to the project.</param>
/// <param name="projectCredentials">The credentials used to authenticate with the Foundry project. This must be a valid instance of <see cref="TokenCredential"/>.</param>
/// <param name="httpClient">An optional <see cref="HttpClient"/> instance to be used for making HTTP requests. If not provided, a default client will be used.</param>
public sealed class FoundryAgentProvider(string projectEndpoint, TokenCredential projectCredentials, HttpClient? httpClient = null) : WorkflowAgentProvider
{
private PersistentAgentsClient? _agentsClient;
/// <inheritdoc/>
public override async Task<AIAgent> 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;
}
/// <summary>
/// Asynchronously creates a new conversation and returns its unique identifier.
/// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The conversation identifier</returns>
public abstract Task<string> CreateConversationAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new message in the specified conversation.
/// </summary>
/// <param name="conversationId">The identifier of the target conversation.</param>
/// <param name="conversationMessage">The message being added.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
public abstract Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a specific message from a conversation.
/// </summary>
/// <param name="conversationId">The identifier of the target conversation.</param>
/// <param name="messageId">The identifier of the target message.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The requested message</returns>
public abstract Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a set of messages from a conversation.
/// </summary>
/// <param name="conversationId">The identifier of the target conversation.</param>
/// <param name="limit">A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 20.</param>
/// <param name="after">A cursor for use in pagination. after is an object ID that defines your place in the list.</param>
/// <param name="before">A cursor for use in pagination. before is an object ID that defines your place in the list.</param>
/// <param name="newestFirst">Provide records in descending order when true.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The requested messages</returns>
public abstract IAsyncEnumerable<ChatMessage> GetMessagesAsync(
string conversationId,
int? limit = null,
string? after = null,
string? before = null,
bool newestFirst = false,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,5 @@
type: foundry_agent
name: BasicAgent
description: Basic agent for integration tests
model:
id: ${AzureAI:ModelDeployment}
@@ -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;
/// <summary>
/// Tests execution of workflow created by <see cref="DeclarativeWorkflowBuilder"/>.
/// </summary>
[Collection("Global")]
public sealed class DeclarativeWorkflowTest(ITestOutputHelper output, AgentFixture agentFixture) : WorkflowTest(output), IClassFixture<AgentFixture>
{
[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<ChatMessage>(testcase, workflowPath, configuration),
nameof(String) => this.RunWorkflow<string>(testcase, workflowPath, configuration),
_ => throw new NotSupportedException($"Input type '{testcase.Setup.Input.Type}' is not supported."),
};
}
private async Task RunWorkflow<TInput>(
Testcase testcase,
string workflowPath,
IConfiguration configuration) where TInput : notnull
{
this.Output.WriteLine($"INPUT: {testcase.Setup.Input.Value}");
AzureAIConfiguration? foundryConfig = configuration.GetSection("AzureAI").Get<AzureAIConfiguration>();
Assert.NotNull(foundryConfig);
IDictionary<string, string?> 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<TInput> workflow = DeclarativeWorkflowBuilder.Build<TInput>(workflowPath, workflowOptions);
WorkflowEvents workflowEvents = await WorkflowHarness.RunAsync(workflow, (TInput)GetInput<TInput>(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<TInput>(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<Testcase>(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,
};
}
@@ -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<ImmutableDictionary<string, string?>> 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<string, string?> 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();
}
}
@@ -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<string, string?>? s_agentMap;
internal async Task<ImmutableDictionary<string, string?>> GetAgentsAsync(AzureAIConfiguration config, CancellationToken cancellationToken = default)
{
s_agentMap ??= await AgentFactory.CreateAsync("Agents", config, cancellationToken);
return s_agentMap;
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
}
@@ -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<string> _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>(TState state) where TState : notnull
{
this._scopes.Push($"{state}");
return new LoggerScope(() => this._scopes.Pop());
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> 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;
}
}
}
}
@@ -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; }
}
@@ -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<WorkflowEvent> workflowEvents)
{
this.Events = workflowEvents;
this.EventCounts = workflowEvents.GroupBy(e => e.GetType()).ToImmutableDictionary(e => e.Key, e => e.Count());
this.ActionInvokeEvents = workflowEvents.OfType<DeclarativeActionInvokeEvent>().ToImmutableList();
this.ActionCompleteEvents = workflowEvents.OfType<DeclarativeActionCompleteEvent>().ToImmutableList();
}
public ImmutableList<WorkflowEvent> Events { get; }
public IImmutableDictionary<Type, int> EventCounts { get; }
public ImmutableList<DeclarativeActionInvokeEvent> ActionInvokeEvents { get; }
public ImmutableList<DeclarativeActionCompleteEvent> ActionCompleteEvents { get; private set; }
}
@@ -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<WorkflowEvents> RunAsync<TInput>(Workflow<TInput> workflow, TInput input) where TInput : notnull
{
StreamingRun run = await InProcessExecution.StreamAsync(workflow, input);
ImmutableList<WorkflowEvent> workflowEvents = run.WatchStreamAsync().ToEnumerable().ToImmutableList();
return new WorkflowEvents(workflowEvents);
}
}
@@ -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;
/// <summary>
/// Base class for workflow tests.
/// </summary>
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}";
}
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<NoWarn>$(NoWarn);IDE1006</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedIntegrationTestCode>true</InjectSharedIntegrationTestCode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.Workflows.Declarative\Microsoft.Agents.Workflows.Declarative.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.SemanticKernel.Agents.Abstractions" />
<PackageReference Include="Microsoft.SemanticKernel.Agents.AzureAI" />
<PackageReference Include="Microsoft.SemanticKernel.Agents.Yaml" />
</ItemGroup>
<ItemGroup>
<None Update="Agents\*.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Testcases\*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Workflows\*.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -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
}
}
@@ -0,0 +1,12 @@
{
"description": "Send an activity message .",
"setup": {
"input": {
"type": "String",
"value": "Why is the sky blue?"
}
},
"validation": {
"action_count": 3
}
}
@@ -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!
@@ -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}
@@ -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}
@@ -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
@@ -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}"
You said, "{Topic.UserInput}"
@@ -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<AgentRunResponseEvent>(), e => string.Equals(e.Response.Messages[0].Text.Trim(), message, StringComparison.Ordinal));
Assert.Contains(this.WorkflowEvents.OfType<MessageActivityEvent>(), e => string.Equals(e.Message.Trim(), message, StringComparison.Ordinal));
}
private Task RunWorkflow(string workflowPath) => this.RunWorkflow<string>(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<RootExecutor>(WorkflowActionVisitor.RootId("workflow")),
ReflectingExecutor<RootExecutor>(WorkflowActionVisitor.Steps.Root("anything")),
IMessageHandler<string>
{
public async ValueTask HandleAsync(string message, IWorkflowContext context)
@@ -19,7 +19,7 @@ public class FormulaValueExtensionsTests
BooleanDataValue typedValue = Assert.IsType<BooleanDataValue>(dataValue);
Assert.Equal(formulaValue.Value, typedValue.Value);
BooleanValue formulaCopy = Assert.IsType<BooleanValue>(dataValue.ToFormulaValue());
BooleanValue formulaCopy = Assert.IsType<BooleanValue>(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<StringDataValue>(dataValue);
Assert.Equal(formulaValue.Value, typedValue.Value);
StringValue formulaCopy = Assert.IsType<StringValue>(typedValue.ToFormulaValue());
StringValue formulaCopy = Assert.IsType<StringValue>(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<NumberDataValue>(dataValue);
Assert.Equal(formulaValue.Value, typedValue.Value);
DecimalValue formulaCopy = Assert.IsType<DecimalValue>(typedValue.ToFormulaValue());
DecimalValue formulaCopy = Assert.IsType<DecimalValue>(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<FloatDataValue>(dataValue);
Assert.Equal(formulaValue.Value, typedValue.Value);
NumberValue formulaCopy = Assert.IsType<NumberValue>(typedValue.ToFormulaValue());
NumberValue formulaCopy = Assert.IsType<NumberValue>(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<DateDataValue>(dataValue);
Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), typedValue.Value);
DateValue formulaCopy = Assert.IsType<DateValue>(dataValue.ToFormulaValue());
DateValue formulaCopy = Assert.IsType<DateValue>(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<DateTimeDataValue>(dataValue);
Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), typedValue.Value);
DateTimeValue formulaCopy = Assert.IsType<DateTimeValue>(typedValue.ToFormulaValue());
DateTimeValue formulaCopy = Assert.IsType<DateTimeValue>(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<TimeDataValue>(dataValue);
Assert.Equal(formulaValue.Value, typedValue.Value);
TimeValue formulaCopy = Assert.IsType<TimeValue>(typedValue.ToFormulaValue());
TimeValue formulaCopy = Assert.IsType<TimeValue>(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<RecordValue>(dataValue.ToFormulaValue(), exactMatch: false);
RecordValue formulaCopy = Assert.IsType<RecordValue>(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<string, int> source =
new()
{
["FieldA"] = 1,
["FieldB"] = 2,
["FieldC"] = 3
};
FormulaValue formula = source.ToFormula();
Assert.IsType<RecordValue>(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<TableValue>(dataValue.ToFormulaValue(), exactMatch: false);
TableValue formulaCopy = Assert.IsType<TableValue>(dataValue.ToFormula(), exactMatch: false);
Assert.Equal(formulaCopy.Rows.Count(), dataValue.Values.Length);
Assert.Equal(
@@ -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)
@@ -14,7 +14,7 @@ using Xunit.Abstractions;
namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Base test class for <see cref="WorkflowActionExecutor"/> implementations.
/// Base test class for <see cref="DeclarativeActionExecutor"/> implementations.
/// </summary>
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<WorkflowEvent[]> Execute(WorkflowActionExecutor executor)
internal async Task<WorkflowEvent[]> 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);
}
}
}
+140 -110
View File
@@ -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
+44
View File
@@ -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}
+46
View File
@@ -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.
+14 -8
View File
@@ -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:
-24
View File
@@ -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}