.NET Workflows - Declarative Workflow Integration Tests (#956)

* Checkpoint / 100% Pass

* Checkpoint: ActionExecutorResult

* Update dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Testcases/Marketing.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Namespace

* Sync updates

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Chris
2025-09-29 16:40:15 +00:00
committed by GitHub
co-authored by Copilot
parent 10d10364a9
commit e11ec9d941
49 changed files with 870 additions and 429 deletions
@@ -17,7 +17,7 @@ namespace Microsoft.Agents.Workflows.Declarative;
public static class DeclarativeWorkflowBuilder
{
/// <summary>
/// Builds a workflow from the provided YAML definition.
/// Builder for converting a Foundry workflow object-model YAML definition into a process.
/// </summary>
/// <typeparam name="TInput">The type of the input message</typeparam>
/// <param name="workflowFile">The path to the workflow.</param>
@@ -55,18 +55,19 @@ public static class DeclarativeWorkflowBuilder
throw new DeclarativeModelException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(Workflow)}.");
}
string rootId = WorkflowActionVisitor.Steps.Root(workflowElement.BeginDialog?.Id.Value);
string rootId = WorkflowActionVisitor.Steps.Root(workflowElement);
WorkflowFormulaState state = new(options.CreateRecalcEngine());
state.Initialize(workflowElement.WrapWithBot(), options.Configuration);
DeclarativeWorkflowExecutor<TInput> rootExecutor =
new(rootId,
options.AgentProvider,
state,
message => inputTransform?.Invoke(message) ?? DefaultTransform(message));
WorkflowActionVisitor visitor = new(rootExecutor, state, options);
WorkflowElementWalker walker = new(visitor);
walker.Visit(rootElement);
walker.Visit(workflowElement);
return visitor.Complete();
}
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.Workflows.Declarative.Extensions;
internal static class AgentProviderExtensions
{
public static async IAsyncEnumerable<AgentRunResponseUpdate> InvokeAgentAsync(
this WorkflowAgentProvider agentProvider,
string executorId,
IWorkflowContext context,
string agentName,
string? conversationId,
bool autoSend,
string? additionalInstructions = null,
IEnumerable<ChatMessage>? inputMessages = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
AIAgent agent = await agentProvider.GetAgentAsync(agentName, cancellationToken).ConfigureAwait(false);
ChatClientAgentRunOptions options =
new(
new ChatOptions()
{
Instructions = additionalInstructions,
});
AgentThread agentThread = conversationId is not null && agent is ChatClientAgent chatClientAgent ? chatClientAgent.GetNewThread(conversationId) : agent.GetNewThread();
IAsyncEnumerable<AgentRunResponseUpdate> agentUpdates =
inputMessages is not null ?
agent.RunStreamingAsync([.. inputMessages], agentThread, options, cancellationToken) :
agent.RunStreamingAsync(agentThread, options, cancellationToken);
await foreach (AgentRunResponseUpdate update in agentUpdates.ConfigureAwait(false))
{
await AssignConversationIdAsync(((ChatResponseUpdate?)update.RawRepresentation)?.ConversationId).ConfigureAwait(false);
if (autoSend)
{
await context.AddEventAsync(new AgentRunUpdateEvent(executorId, update)).ConfigureAwait(false);
}
yield return update;
}
async ValueTask AssignConversationIdAsync(string? assignValue)
{
if (assignValue is not null && conversationId is null)
{
conversationId = assignValue;
await context.QueueConversationUpdateAsync(conversationId).ConfigureAwait(false);
}
}
}
}
@@ -16,10 +16,15 @@ internal static class ChatMessageExtensions
FormulaValue.NewRecordFromFields(message.GetMessageFields());
public static TableValue ToTable(this IEnumerable<ChatMessage> messages) =>
FormulaValue.NewTable(s_messageRecordType, messages.Select(message => message.ToRecord()));
FormulaValue.NewTable(TypeSchema.Message.MessageRecordType, messages.Select(message => message.ToRecord()));
public static IEnumerable<ChatMessage> ToChatMessages(this DataValue messages)
public static IEnumerable<ChatMessage>? ToChatMessages(this DataValue? messages)
{
if (messages is null || messages is BlankDataValue)
{
return null;
}
if (messages is TableDataValue table)
{
return table.ToChatMessages();
@@ -35,7 +40,7 @@ internal static class ChatMessageExtensions
return [text.ToChatMessage()];
}
return [];
return null;
}
public static IEnumerable<ChatMessage> ToChatMessages(this TableDataValue messages)
@@ -185,10 +190,11 @@ internal static class ChatMessageExtensions
private static IEnumerable<NamedValue> GetMessageFields(this ChatMessage message)
{
yield return new NamedValue(TypeSchema.Discriminator, nameof(ChatMessage).ToFormula());
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, FormulaValue.NewTable(s_contentRecordType, message.GetContentRecords()));
yield return new NamedValue(TypeSchema.Message.Fields.Content, FormulaValue.NewTable(TypeSchema.Message.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());
}
@@ -228,18 +234,4 @@ internal static class ChatMessageExtensions
}
}
}
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,7 +9,9 @@ using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Agents.Workflows.Declarative.PowerFx;
using Microsoft.Agents.Workflows.Declarative.PowerFx.Functions;
using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using BlankType = Microsoft.PowerFx.Types.BlankType;
@@ -37,6 +39,7 @@ internal static class FormulaValueExtensions
DateTime dateonlyValue when dateonlyValue.TimeOfDay == TimeSpan.Zero => FormulaValue.NewDateOnly(dateonlyValue),
DateTime datetimeValue => FormulaValue.New(datetimeValue),
TimeSpan timeValue => FormulaValue.New(timeValue),
ChatMessage chatMessage => chatMessage.ToRecord(),
ExpandoObject expandoValue => expandoValue.ToRecord(),
object when value is IDictionary dictionaryValue => dictionaryValue.ToRecord(),
object when value is IEnumerable tableValue => tableValue.ToTable(),
@@ -209,10 +212,40 @@ internal static class FormulaValueExtensions
return TableType.Empty();
}
private static TableValue ToTable(this IEnumerable value) =>
FormulaValue.NewTable(
value.ToTableType().ToRecord(),
[.. value.OfType<ExpandoObject>().Select(element => element.ToRecord())]);
private static TableValue ToTable(this IEnumerable value)
{
Type? elementType = value.GetType().GetElementType();
if (elementType is null || elementType == typeof(object))
{
IEnumerator enumerator = value.GetEnumerator();
if (enumerator.MoveNext())
{
elementType = enumerator.Current?.GetType();
}
}
return
elementType switch
{
null => FormulaValue.NewTable(RecordType.EmptySealed(), []),
_ when elementType == typeof(ExpandoObject) =>
FormulaValue.NewTable(
value.ToTableType().ToRecord(),
[.. value.OfType<ExpandoObject>().Select(element => element.ToRecord())]),
_ when typeof(ChatMessage).IsAssignableFrom(elementType) =>
FormulaValue.NewTable(
TypeSchema.Message.MessageRecordType,
[.. value.OfType<ChatMessage>().Select(message => message.ToRecord())]),
_ when typeof(IDictionary).IsAssignableFrom(elementType) => value.ToTableOfRecords(),
_ => throw new DeclarativeModelException($"Unsupported element type: {elementType.Name}"),
};
}
private static TableValue ToTableOfRecords(this IEnumerable list)
{
RecordValue[] elements = [.. list.OfType<IDictionary>().Select(table => table.ToRecord())];
return FormulaValue.NewTable(elements.First().Type, elements);
}
private static KeyValuePair<string, DataValue> GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.ToDataValue());
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.Workflows.Declarative.Interpreter;
@@ -20,7 +19,7 @@ internal static class IWorkflowContextExtensions
context.AddEventAsync(new DeclarativeActionCompletedEvent(action));
public static ValueTask SendResultMessageAsync(this IWorkflowContext context, string id, object? result = null, CancellationToken cancellationToken = default) =>
context.SendMessageAsync(new ExecutorResultMessage(id, result));
context.SendMessageAsync(new ActionExecutorResult(id, result));
public static ValueTask QueueStateResetAsync(this IWorkflowContext context, PropertyPath variablePath) =>
context.QueueStateUpdateAsync(Throw.IfNull(variablePath.VariableName), UnassignedValue.Instance, Throw.IfNull(variablePath.NamespaceAlias));
@@ -47,25 +46,6 @@ internal static class IWorkflowContextExtensions
await context.AddEventAsync(new ConversationUpdateEvent(conversationId)).ConfigureAwait(false);
}
// Ensure "System.Conversation.Id" and "System.ConversationId" are properly initialized when referenced.
public static async ValueTask EnsureWorkflowConversationAsync(this IWorkflowContext context, WorkflowAgentProvider agentProvider, StringExpression expression, CancellationToken cancellationToken)
{
if (expression.IsVariableReference &&
expression.VariableReference.IsVariableReferenceWithScope(VariableNamespace.System, out string? variableName))
{
if (string.Equals(variableName, SystemScope.Names.Conversation, StringComparison.Ordinal) ||
string.Equals(variableName, SystemScope.Names.ConversationId, StringComparison.Ordinal))
{
FormulaValue variableValue = context.ReadState(SystemScope.Names.ConversationId, VariableScopeNames.System);
if (variableValue is BlankValue)
{
string conversationId = await agentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false);
await context.QueueConversationUpdateAsync(conversationId).ConfigureAwait(false);
}
}
}
}
private static DeclarativeWorkflowContext DeclarativeContext(IWorkflowContext context)
{
if (context is not DeclarativeWorkflowContext declarativeContext)
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.Workflows.Declarative.Interpreter;
/// <summary>
/// Message sent to initiate a transition to another <see cref="Executor"/>.
/// </summary>
public sealed record class ActionExecutorResult
{
/// <summary>
/// The identifier of the <see cref="Executor"/> that produced this message.
/// </summary>
public string ExecutorId { get; }
/// <summary>
/// The result of the action, if any provided.
/// </summary>
public object? Result { get; }
internal ActionExecutorResult(string executorId, object? result = null)
{
this.ExecutorId = executorId;
this.Result = result;
}
internal static ActionExecutorResult ThrowIfNot(object? message)
{
if (message is not ActionExecutorResult executorMessage)
{
throw new DeclarativeActionException($"Unexpected message type: {message?.GetType().Name ?? "(null)"} (Expected: {nameof(ActionExecutorResult)})");
}
return executorMessage;
}
}
@@ -21,7 +21,7 @@ internal abstract class DeclarativeActionExecutor<TAction>(TAction model, Workfl
public new TAction Model => (TAction)base.Model;
}
internal abstract class DeclarativeActionExecutor : Executor<ExecutorResultMessage>
internal abstract class DeclarativeActionExecutor : Executor<ActionExecutorResult>
{
private string? _parentId;
private readonly WorkflowFormulaState _state;
@@ -54,7 +54,7 @@ internal abstract class DeclarativeActionExecutor : Executor<ExecutorResultMessa
protected virtual bool EmitResultEvent => true;
/// <inheritdoc/>
public override async ValueTask HandleAsync(ExecutorResultMessage message, IWorkflowContext context)
public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context)
{
if (this.Model.Disabled)
{
@@ -64,11 +64,10 @@ internal abstract class DeclarativeActionExecutor : Executor<ExecutorResultMessa
await context.RaiseInvocationEventAsync(this.Model, message.ExecutorId).ConfigureAwait(false);
Debug.WriteLine($"RESULT #{this.Id} - {message.Result ?? "(null)"}");
try
{
object? result = await this.ExecuteAsync(new DeclarativeWorkflowContext(context, this._state), cancellationToken: default).ConfigureAwait(false);
Debug.WriteLine($"RESULT #{this.Id} - {result ?? "(null)"}");
if (this.EmitResultEvent)
{
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Linq;
@@ -75,7 +74,23 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext
}
/// <inheritdoc/>
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null) => this.Source.ReadStateAsync<T>(key, scopeName);
public async ValueTask<TValue?> ReadStateAsync<TValue>(string key, string? scopeName = null)
{
bool isManagedScope =
scopeName is not null && // null scope cannot be managed
VariableScopeNames.IsValidName(scopeName);
return typeof(TValue) switch
{
// Not a managed scope, just pass through. This is valid when a declarative
// workflow has been ejected to code (where DeclarativeWorkflowContext is also utilized).
_ when !isManagedScope => await this.Source.ReadStateAsync<TValue>(key, scopeName).ConfigureAwait(false),
// Retrieve formula values directly from the managed state to avoid conversion.
_ when typeof(TValue) == typeof(FormulaValue) => (TValue?)(object?)this.State.Get(key, scopeName),
// Retrieve native types from the source context to avoid conversion.
_ => await this.Source.ReadStateAsync<TValue>(key, scopeName).ConfigureAwait(false),
};
}
/// <inheritdoc/>
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null) => this.Source.ReadStateKeysAsync(scopeName);
@@ -86,9 +101,8 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext
private ValueTask UpdateStateAsync<T>(string key, T? value, string? scopeName, bool allowSystem = true)
{
bool isManagedScope =
scopeName != null && // null scope cannot be managed
(ManagedScopes.Contains(scopeName) ||
(allowSystem && VariableScopeNames.System.Equals(scopeName, StringComparison.Ordinal)));
scopeName is not null && // null scope cannot be managed
VariableScopeNames.IsValidName(scopeName);
if (!isManagedScope)
{
@@ -97,6 +111,11 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext
return this.Source.QueueStateUpdateAsync(key, value, scopeName);
}
if (!ManagedScopes.Contains(scopeName!) && !allowSystem)
{
throw new DeclarativeActionException($"Cannot manage variable definitions in scope: '{scopeName}'.");
}
return value switch
{
null => QueueEmptyStateAsync(),
@@ -127,19 +146,19 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext
ValueTask QueueDataValueStateAsync(DataValue dataValue)
{
FormulaValue formulaValue = dataValue.ToFormula();
if (isManagedScope)
{
FormulaValue formulaValue = dataValue.ToFormula();
this.State.Set(key, formulaValue, scopeName);
}
return this.Source.QueueStateUpdateAsync(key, formulaValue.ToObject(), scopeName);
return this.Source.QueueStateUpdateAsync(key, dataValue.ToObject(), scopeName);
}
ValueTask QueueNativeStateAsync(object? rawValue)
{
FormulaValue formulaValue = rawValue.ToFormula();
if (isManagedScope)
{
FormulaValue formulaValue = rawValue.ToFormula();
this.State.Set(key, formulaValue, scopeName);
}
return this.Source.QueueStateUpdateAsync(key, rawValue, scopeName);
@@ -2,6 +2,7 @@
using System;
using System.Threading.Tasks;
using Microsoft.Agents.Workflows.Declarative.Extensions;
using Microsoft.Agents.Workflows.Declarative.PowerFx;
using Microsoft.Extensions.AI;
@@ -12,6 +13,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Interpreter;
/// </summary>
internal sealed class DeclarativeWorkflowExecutor<TInput>(
string workflowId,
WorkflowAgentProvider agentProvider,
WorkflowFormulaState state,
Func<TInput, ChatMessage> inputTransform) :
Executor<TInput>(workflowId)
@@ -22,9 +24,15 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
// No state to restore if we're starting from the beginning.
state.SetInitialized();
DeclarativeWorkflowContext declarativeContext = new(context, state);
ChatMessage input = inputTransform.Invoke(message);
state.SetLastMessage(input);
await context.SendMessageAsync(new ExecutorResultMessage(this.Id)).ConfigureAwait(false);
string conversationId = await agentProvider.CreateConversationAsync(cancellationToken: default).ConfigureAwait(false);
await declarativeContext.QueueConversationUpdateAsync(conversationId).ConfigureAwait(false);
await agentProvider.CreateMessageAsync(conversationId, input, cancellationToken: default).ConfigureAwait(false);
await declarativeContext.SetLastMessageAsync(input).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id).ConfigureAwait(false);
}
}
@@ -10,10 +10,10 @@ namespace Microsoft.Agents.Workflows.Declarative.Interpreter;
internal delegate ValueTask DelegateAction<TMessage>(IWorkflowContext context, TMessage message, CancellationToken cancellationToken) where TMessage : notnull;
internal sealed class DelegateActionExecutor(string actionId, WorkflowFormulaState state, DelegateAction<ExecutorResultMessage>? action = null, bool emitResult = true)
: DelegateActionExecutor<ExecutorResultMessage>(actionId, state, action, emitResult)
internal sealed class DelegateActionExecutor(string actionId, WorkflowFormulaState state, DelegateAction<ActionExecutorResult>? action = null, bool emitResult = true)
: DelegateActionExecutor<ActionExecutorResult>(actionId, state, action, emitResult)
{
public override ValueTask HandleAsync(ExecutorResultMessage message, IWorkflowContext context)
public override ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context)
{
Debug.WriteLine($"RESULT #{this.Id} - {message.Result ?? "(null)"}");
@@ -1,16 +0,0 @@
// 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;
}
}
@@ -17,9 +17,13 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
internal static class Steps
{
public static string Root(AdaptiveDialog action) => $"{action.BeginDialog?.Id.Value ?? DefaultWorkflowId}_{nameof(Root)}";
public static string Root(string? actionId = null) => $"{actionId ?? DefaultWorkflowId}_{nameof(Root)}";
public static string Post(string actionId) => $"{actionId}_{nameof(Post)}";
public static string Restart(string actionId) => $"{actionId}_{nameof(Restart)}";
}
private readonly WorkflowBuilder _workflowBuilder;
@@ -66,17 +70,21 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
// Complete the action scope.
void CompletionHandler()
{
// No completion for root scope
if (this._workflowModel.GetDepth(item.Id.Value) > 1)
{
DelegateAction<ExecutorResultMessage>? action = null;
DelegateAction<ActionExecutorResult>? action = null;
ConditionGroupExecutor? conditionGroup = this._workflowModel.LocateParent<ConditionGroupExecutor>(parentId);
if (conditionGroup is not null)
{
action = conditionGroup.DoneAsync;
}
string completionId = this.ContinuationFor(item.Id.Value, action); // End scope
this._workflowModel.AddLinkFromPeer(item.Id.Value, completionId); // Connect with final action
this._workflowModel.AddLink(completionId, Steps.Post(parentId)); // Merge with parent scope
// Define post action for this scope
string completionId = this.ContinuationFor(item.Id.Value, action);
this._workflowModel.AddLinkFromPeer(item.Id.Value, completionId);
// Transition to post action of parent scope
this._workflowModel.AddLink(completionId, Steps.Post(parentId));
}
}
}
@@ -85,11 +93,11 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
{
this.Trace(item);
ConditionGroupExecutor? conditionGroup = this._workflowModel.LocateParent<ConditionGroupExecutor>(item.GetParentId());
string parentId = GetParentId(item);
ConditionGroupExecutor? conditionGroup = this._workflowModel.LocateParent<ConditionGroupExecutor>(parentId);
if (conditionGroup is not null)
{
string stepId = ConditionGroupExecutor.Steps.Item(conditionGroup.Model, item);
string parentId = GetParentId(item);
this._workflowModel.AddNode(new DelegateActionExecutor(stepId, this._workflowState), parentId, CompletionHandler);
base.VisitConditionItem(item);
@@ -145,9 +153,12 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
{
this.Trace(item);
GotoExecutor action = new(item, this._workflowState);
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Transition to target action
this._workflowModel.AddLink(action.Id, item.ActionId.Value);
// Define a clean-start to ensure "goto" is not a source for any edge
this.RestartAfter(action.Id, action.ParentId);
}
@@ -155,21 +166,28 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
{
this.Trace(item);
// Entry point for loop
ForeachExecutor action = new(item, this._workflowState);
string loopId = ForeachExecutor.Steps.Next(action.Id);
this.ContinueWith(action, condition: null, CompletionHandler); // Foreach
this.ContinueWith(new DelegateActionExecutor(loopId, this._workflowState, action.TakeNextAsync), action.Id); // Loop Increment
string continuationId = this.ContinuationFor(action.Id, action.ParentId); // Action continuation
this.ContinueWith(action, condition: null, CompletionHandler);
// Transition to select the next item
this.ContinueWith(new DelegateActionExecutor(loopId, this._workflowState, action.TakeNextAsync), action.Id);
// Transition to post action if no more items
string continuationId = this.ContinuationFor(action.Id, action.ParentId);
this._workflowModel.AddLink(loopId, continuationId, (_) => !action.HasValue);
// Transition to start of inner actions if there is a current item
string startId = ForeachExecutor.Steps.Start(action.Id);
this._workflowModel.AddNode(new DelegateActionExecutor(startId, this._workflowState), action.Id);
this._workflowModel.AddLink(loopId, startId, (_) => action.HasValue);
void CompletionHandler()
{
string endActionsId = ForeachExecutor.Steps.End(action.Id); // Loop continuation
// Transition to end of inner actions
string endActionsId = ForeachExecutor.Steps.End(action.Id);
this.ContinueWith(new DelegateActionExecutor(endActionsId, this._workflowState, action.ResetAsync), action.Id);
// Transition to select the next item
this._workflowModel.AddLink(endActionsId, loopId);
}
}
@@ -178,13 +196,18 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
{
this.Trace(item);
ForeachExecutor? loopExecutor = this._workflowModel.LocateParent<ForeachExecutor>(item.GetParentId());
if (loopExecutor is not null)
// Locate the nearest "Foreach" loop that contains this action
ForeachExecutor? loopAction = this._workflowModel.LocateParent<ForeachExecutor>(item.GetParentId());
// Skip action if its not contained a loop
if (loopAction is not null)
{
DefaultActionExecutor breakLoopExecutor = new(item, this._workflowState);
this.ContinueWith(breakLoopExecutor);
this._workflowModel.AddLink(breakLoopExecutor.Id, Steps.Post(loopExecutor.Id));
this.RestartAfter(breakLoopExecutor.Id, breakLoopExecutor.ParentId);
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Transition to post action
this._workflowModel.AddLink(action.Id, Steps.Post(loopAction.Id));
// Define a clean-start to ensure "break" is not a source for any edge
this.RestartAfter(action.Id, action.ParentId);
}
}
@@ -192,34 +215,21 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
{
this.Trace(item);
ForeachExecutor? loopExecutor = this._workflowModel.LocateParent<ForeachExecutor>(item.GetParentId());
if (loopExecutor is not null)
// Locate the nearest "Foreach" loop that contains this action
ForeachExecutor? loopAction = this._workflowModel.LocateParent<ForeachExecutor>(item.GetParentId());
// Skip action if its not contained a loop
if (loopAction is not null)
{
DefaultActionExecutor continueLoopExecutor = new(item, this._workflowState);
this.ContinueWith(continueLoopExecutor);
this._workflowModel.AddLink(continueLoopExecutor.Id, ForeachExecutor.Steps.Next(loopExecutor.Id));
this.RestartAfter(continueLoopExecutor.Id, continueLoopExecutor.ParentId);
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Transition to select the next item
this._workflowModel.AddLink(action.Id, ForeachExecutor.Steps.Next(loopAction.Id));
// Define a clean-start to ensure "continue" is not a source for any edge
this.RestartAfter(action.Id, action.ParentId);
}
}
protected override void Visit(EndConversation item)
{
this.Trace(item);
DefaultActionExecutor endExecutor = new(item, this._workflowState);
this.ContinueWith(endExecutor);
this.RestartAfter(item.Id.Value, endExecutor.ParentId);
}
protected override void Visit(EndDialog item)
{
this.Trace(item);
DefaultActionExecutor endExecutor = new(item, this._workflowState);
this.ContinueWith(endExecutor);
this.RestartAfter(item.Id.Value, endExecutor.ParentId);
}
protected override void Visit(Question item)
{
this.Trace(item);
@@ -228,25 +238,55 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
string actionId = item.GetId();
string postId = Steps.Post(actionId);
QuestionExecutor questionExecutor = new(item, this._workflowState);
this.ContinueWith(questionExecutor);
// Entry point for question
QuestionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Transition to post action if complete
this._workflowModel.AddLink(actionId, postId, QuestionExecutor.IsComplete);
// Perpare for input request if not complete
string prepareId = QuestionExecutor.Steps.Prepare(actionId);
this.ContinueWith(new DelegateActionExecutor(prepareId, this._workflowState, questionExecutor.PrepareResponseAsync, emitResult: false), parentId, message => !QuestionExecutor.IsComplete(message));
this.ContinueWith(new DelegateActionExecutor(prepareId, this._workflowState, action.PrepareResponseAsync, emitResult: false), parentId, message => !QuestionExecutor.IsComplete(message));
// Define input action
string inputId = QuestionExecutor.Steps.Input(actionId);
//ModeledPort inputPort = new(InputPort.Create<InputRequest, InputResponse>(inputId)); // %%% MODELING
InputPort inputPort = InputPort.Create<InputRequest, InputResponse>(inputId);
this._workflowModel.AddPort(inputPort, parentId);
this._workflowModel.AddLinkFromPeer(parentId, inputId);
// Capture input response
string captureId = QuestionExecutor.Steps.Capture(actionId);
this.ContinueWith(new DelegateActionExecutor<InputResponse>(captureId, this._workflowState, questionExecutor.CaptureResponseAsync, emitResult: false), parentId);
this.ContinueWith(new DelegateActionExecutor<InputResponse>(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), parentId);
this.ContinueWith(new DelegateActionExecutor(postId, this._workflowState, questionExecutor.CompleteAsync), parentId, QuestionExecutor.IsComplete);
// Transition to post action if complete
this.ContinueWith(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), parentId, QuestionExecutor.IsComplete);
// Transition to prepare action if not complete
this._workflowModel.AddLink(captureId, prepareId, message => !QuestionExecutor.IsComplete(message));
}
protected override void Visit(EndDialog item)
{
this.Trace(item);
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Define a clean-start to ensure "end" is not a source for any edge
this.RestartAfter(item.Id.Value, action.ParentId);
}
protected override void Visit(EndConversation item)
{
this.Trace(item);
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Define a clean-start to ensure "end" is not a source for any edge
this.RestartAfter(action.Id, action.ParentId);
}
protected override void Visit(CreateConversation item)
{
this.Trace(item);
@@ -354,15 +394,9 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
#region Not supported
protected override void Visit(AnswerQuestionWithAI item)
{
this.NotSupported(item);
}
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
protected override void Visit(DeleteActivity item)
{
this.NotSupported(item);
}
protected override void Visit(DeleteActivity item) => this.NotSupported(item);
protected override void Visit(GetActivityMembers item) => this.NotSupported(item);
@@ -386,10 +420,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
protected override void Visit(AdaptiveCardPrompt item) => this.NotSupported(item);
protected override void Visit(CSATQuestion item)
{
this.NotSupported(item);
}
protected override void Visit(CSATQuestion item) => this.NotSupported(item);
protected override void Visit(OAuthInput item) => this.NotSupported(item);
@@ -452,9 +483,9 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
this._workflowModel.AddLinkFromPeer(parentId, executor.Id, condition);
}
private string ContinuationFor(string parentId, DelegateAction<ExecutorResultMessage>? stepAction = null) => this.ContinuationFor(parentId, parentId, stepAction);
private string ContinuationFor(string parentId, DelegateAction<ActionExecutorResult>? stepAction = null) => this.ContinuationFor(parentId, parentId, stepAction);
private string ContinuationFor(string actionId, string parentId, DelegateAction<ExecutorResultMessage>? stepAction = null)
private string ContinuationFor(string actionId, string parentId, DelegateAction<ActionExecutorResult>? stepAction = null)
{
actionId = Steps.Post(actionId);
this._workflowModel.AddNode(new DelegateActionExecutor(actionId, this._workflowState, stepAction), parentId);
@@ -18,8 +18,6 @@ internal sealed class AddConversationMessageExecutor(AddConversationMessage mode
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}");
await context.EnsureWorkflowConversationAsync(agentProvider, this.Model.ConversationId, cancellationToken).ConfigureAwait(false);
string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value;
ChatMessage newMessage = new(this.Model.Role.Value.ToChatRole(), [.. this.GetContent()]) { AdditionalProperties = this.GetMetadata() };
@@ -37,13 +37,13 @@ internal sealed class ConditionGroupExecutor : DeclarativeActionExecutor<Conditi
public bool IsMatch(ConditionItem conditionItem, object? message)
{
ExecutorResultMessage executorMessage = ExecutorResultMessage.ThrowIfNot(message);
ActionExecutorResult executorMessage = ActionExecutorResult.ThrowIfNot(message);
return string.Equals(Steps.Item(this.Model, conditionItem), executorMessage.Result as string, StringComparison.Ordinal);
}
public bool IsElse(object? message)
{
ExecutorResultMessage executorMessage = ExecutorResultMessage.ThrowIfNot(message);
ActionExecutorResult executorMessage = ActionExecutorResult.ThrowIfNot(message);
return string.Equals(Steps.Else(this.Model), executorMessage.Result as string, StringComparison.Ordinal);
}
@@ -67,6 +67,6 @@ internal sealed class ConditionGroupExecutor : DeclarativeActionExecutor<Conditi
return Steps.Else(this.Model);
}
public async ValueTask DoneAsync(IWorkflowContext context, ExecutorResultMessage _, CancellationToken cancellationToken) =>
public async ValueTask DoneAsync(IWorkflowContext context, ActionExecutorResult _, CancellationToken cancellationToken) =>
await context.RaiseCompletionEventAsync(this.Model).ConfigureAwait(false);
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.Workflows.Declarative.Extensions;
@@ -18,14 +19,13 @@ internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}");
await context.EnsureWorkflowConversationAsync(agentProvider, this.Model.ConversationId, cancellationToken).ConfigureAwait(false);
string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value;
DataValue? inputMessages = this.GetInputMessages();
IEnumerable<ChatMessage>? inputMessages = this.GetInputMessages();
if (inputMessages is not null)
{
foreach (ChatMessage message in inputMessages.ToChatMessages())
foreach (ChatMessage message in inputMessages)
{
await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false);
}
@@ -34,7 +34,7 @@ internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages
return default;
}
private DataValue? GetInputMessages()
private IEnumerable<ChatMessage>? GetInputMessages()
{
DataValue? messages = null;
@@ -44,6 +44,6 @@ internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages
messages = expressionResult.Value;
}
return messages;
return messages?.ToChatMessages();
}
}
@@ -1,19 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.Workflows.Declarative.Interpreter;
using Microsoft.Agents.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
namespace Microsoft.Agents.Workflows.Declarative.ObjectModel;
internal sealed class GotoExecutor(GotoAction model, WorkflowFormulaState 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;
}
}
@@ -28,71 +28,31 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
string agentName = this.GetAgentName();
string? additionalInstructions = this.GetAdditionalInstructions();
bool autoSend = this.GetAutoSendValue();
DataValue? inputMessages = this.GetInputMessages();
IEnumerable<ChatMessage>? inputMessages = this.GetInputMessages();
AgentRunResponse agentResponse = InvokeAgentAsync().ToEnumerable().ToAgentRunResponse();
AgentRunResponse agentResponse = agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, inputMessages, cancellationToken).ToEnumerable().ToAgentRunResponse();
if (autoSend)
{
await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false);
}
ChatMessage response = agentResponse.Messages[agentResponse.Messages.Count - 1];
await this.AssignAsync(this.AgentOutput?.Messages?.Path, response.ToRecord(), context).ConfigureAwait(false);
await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), 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 = conversationId is not null && agent is ChatClientAgent chatClientAgent ? chatClientAgent.GetNewThread(conversationId) : agent.GetNewThread();
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 AssignConversationIdAsync(((ChatResponseUpdate?)update.RawRepresentation)?.ConversationId).ConfigureAwait(false);
if (autoSend)
{
await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update)).ConfigureAwait(false);
}
yield return update;
}
}
async ValueTask AssignConversationIdAsync(string? assignValue)
{
if (assignValue is not null && conversationId is null)
{
conversationId = assignValue;
await context.QueueConversationUpdateAsync(conversationId).ConfigureAwait(false);
}
}
}
private DataValue? GetInputMessages()
private IEnumerable<ChatMessage>? GetInputMessages()
{
DataValue? userInput = null;
if (this.AgentInput?.Messages is not null)
{
EvaluationResult<DataValue> expressionResult = this.Evaluator.GetValue(this.AgentInput.Messages);
userInput = expressionResult.Value;
}
return userInput;
return userInput?.ToChatMessages();
}
private string? GetConversationId()
@@ -29,9 +29,9 @@ internal sealed class QuestionExecutor(Question model, WorkflowFormulaState stat
protected override bool IsDiscreteAction => false;
protected override bool EmitResultEvent => false;
public static bool IsComplete(object? message)
public static bool IsComplete(object? message) // %%% BASE CLASS ???
{
ExecutorResultMessage executorMessage = ExecutorResultMessage.ThrowIfNot(message);
ActionExecutorResult executorMessage = ActionExecutorResult.ThrowIfNot(message);
return executorMessage.Result is null;
}
@@ -69,7 +69,7 @@ internal sealed class QuestionExecutor(Question model, WorkflowFormulaState stat
return default;
}
public async ValueTask PrepareResponseAsync(IWorkflowContext context, ExecutorResultMessage message, CancellationToken cancellationToken)
public async ValueTask PrepareResponseAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
int count = await this._promptCount.ReadAsync(context).ConfigureAwait(false);
InputRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt));
@@ -111,7 +111,7 @@ internal sealed class QuestionExecutor(Question model, WorkflowFormulaState stat
}
}
public async ValueTask CompleteAsync(IWorkflowContext context, ExecutorResultMessage message, CancellationToken cancellationToken)
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
await context.RaiseCompletionEventAsync(this.Model).ConfigureAwait(false);
}
@@ -17,7 +17,6 @@ internal sealed class RetrieveConversationMessageExecutor(RetrieveConversationMe
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}");
await context.EnsureWorkflowConversationAsync(agentProvider, this.Model.ConversationId, cancellationToken).ConfigureAwait(false);
string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value;
string messageId = this.Evaluator.GetValue(Throw.IfNull(this.Model.MessageId, $"{nameof(this.Model)}.{nameof(this.Model.MessageId)}")).Value;
@@ -19,7 +19,6 @@ internal sealed class RetrieveConversationMessagesExecutor(RetrieveConversationM
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}");
await context.EnsureWorkflowConversationAsync(agentProvider, this.Model.ConversationId, cancellationToken).ConfigureAwait(false);
string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value;
ChatMessage[] messages = await agentProvider.GetMessagesAsync(
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.Workflows.Declarative.Extensions;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx;
using Microsoft.PowerFx.Types;
@@ -18,6 +19,7 @@ internal sealed class UserMessage : ReflectionFunction
string.IsNullOrEmpty(input.Value) ?
FormulaValue.NewBlank(RecordType.Empty()) :
FormulaValue.NewRecordFromFields(
new NamedValue(TypeSchema.Discriminator, nameof(ChatMessage).ToFormula()),
new NamedValue(TypeSchema.Message.Fields.Role, FormulaValue.New(ChatRole.User.Value)),
new NamedValue(
TypeSchema.Message.Fields.Content,
@@ -3,6 +3,7 @@
using System;
using System.Collections.Frozen;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.Agents.Workflows.Declarative.Extensions;
using Microsoft.Bot.ObjectModel;
using Microsoft.Bot.ObjectModel.SystemVariables;
@@ -89,11 +90,10 @@ internal static class SystemScope
}
}
public static void SetLastMessage(this WorkflowFormulaState state, ChatMessage message)
public static async ValueTask SetLastMessageAsync(this IWorkflowContext context, ChatMessage message)
{
state.Set(Names.LastMessage, message.ToRecord(), VariableScopeNames.System);
state.Set(Names.LastMessageId, message.MessageId is null ? FormulaValue.NewBlank(FormulaType.String) : FormulaValue.New(message.MessageId), VariableScopeNames.System);
state.Set(Names.LastMessageText, FormulaValue.New(message.Text), VariableScopeNames.System);
state.Bind();
await context.QueueSystemUpdateAsync(Names.LastMessage, message.ToRecord()).ConfigureAwait(false);
await context.QueueSystemUpdateAsync<object>(Names.LastMessageId, string.IsNullOrEmpty(message.MessageId) ? UnassignedValue.Instance : message.MessageId).ConfigureAwait(false);
await context.QueueSystemUpdateAsync(Names.LastMessageText, FormulaValue.New(message.Text)).ConfigureAwait(false);
}
}
@@ -1,11 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Bot.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.Workflows.Declarative.PowerFx.Functions;
internal static class TypeSchema
{
public const string Discriminator = "__type__";
public static class Message
{
public static class Fields
@@ -29,5 +32,19 @@ internal static class TypeSchema
public const string ImageUrl = nameof(AgentMessageContentType.ImageUrl);
public const string ImageFile = nameof(AgentMessageContentType.ImageFile);
}
public static readonly RecordType ContentRecordType =
RecordType.Empty()
.Add(Fields.ContentType, FormulaType.String)
.Add(Fields.ContentValue, FormulaType.String);
public static readonly RecordType MessageRecordType =
RecordType.Empty()
.Add(Fields.Id, FormulaType.String)
.Add(Fields.Role, FormulaType.String)
.Add(Fields.Author, FormulaType.String)
.Add(Fields.Content, ContentRecordType.ToTable())
.Add(Fields.Text, FormulaType.String)
.Add(Fields.Metadata, RecordType.Empty());
}
}
@@ -2,4 +2,4 @@ type: foundry_agent
name: BasicAgent
description: Basic agent for integration tests
model:
id: ${AzureAI:ModelDeployment}
id: ${AzureAI:DeploymentMini}
@@ -15,7 +15,7 @@ using Xunit.Abstractions;
namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests;
public sealed class AzureAgentProviderTest(ITestOutputHelper output) : WorkflowTest(output)
public sealed class AzureAgentProviderTest(ITestOutputHelper output) : IntegrationTest(output)
{
private AzureAIConfiguration? _configuration;
@@ -1,16 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Linq;
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;
@@ -24,52 +17,20 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
[Theory]
[InlineData("SendActivity.yaml", "SendActivity.json")]
[InlineData("InvokeAgent.yaml", "InvokeAgent.json")]
public Task ValidateAsync(string workflowFileName, string testcaseFileName) =>
this.RunWorkflowAsync(workflowFileName, testcaseFileName);
[InlineData("ConversationMessages.yaml", "ConversationMessages.json")]
public Task ValidateCaseAsync(string workflowFileName, string testcaseFileName) =>
this.RunWorkflowAsync(Path.Combine("Workflows", workflowFileName), testcaseFileName);
private Task RunWorkflowAsync(string workflowFileName, string testcaseFileName)
[Theory]
[InlineData("Marketing.yaml", "Marketing.json")]
[InlineData("MathChat.yaml", "MathChat.json")]
[InlineData("DeepResearch.yaml", "DeepResearch.json")]
[InlineData("HumanInLoop.yaml", "HumanInLoop.json", Skip = "TODO")]
public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName) =>
this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName), testcaseFileName);
protected override async Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions)
{
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.RunWorkflowAsync<ChatMessage>(testcase, workflowPath, configuration),
nameof(String) => this.RunWorkflowAsync<string>(testcase, workflowPath, configuration),
_ => throw new NotSupportedException($"Input type '{testcase.Setup.Input.Type}' is not supported."),
};
}
private async Task RunWorkflowAsync<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);
IReadOnlyDictionary<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 workflow = DeclarativeWorkflowBuilder.Build<TInput>(workflowPath, workflowOptions);
WorkflowEvents workflowEvents = await WorkflowHarness.RunAsync(workflow, (TInput)GetInput<TInput>(testcase));
@@ -78,30 +39,10 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
this.Output.WriteLine($"ACTION: {actionInvokeEvent.ActionId} [{actionInvokeEvent.ActionType}]");
}
Assert.Equal(testcase.Validation.ActionCount, workflowEvents.ActionInvokeEvents.Count);
Assert.Equal(testcase.Validation.ActionCount, workflowEvents.ActionCompleteEvents.Count);
Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents);
Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents);
AssertWorkflow.EventCounts(workflowEvents.ActionInvokeEvents.Count, testcase);
AssertWorkflow.EventCounts(workflowEvents.ActionCompleteEvents.Count, testcase);
AssertWorkflow.EventSequence(workflowEvents.ActionInvokeEvents.Select(e => e.ActionId), testcase);
}
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 readonly JsonSerializerOptions s_jsonSerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = true,
};
}
@@ -1,12 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Agents.Persistent;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents;
@@ -19,31 +24,67 @@ namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework;
internal static class AgentFactory
{
public static async Task<IReadOnlyDictionary<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"))
private static readonly Dictionary<string, string> _agentDefinitions =
new()
{
Debug.WriteLine($"TEST AGENT: Creating - {file}");
string agentText = File.ReadAllText(file);
["FOUNDRY_AGENT_TEST"] = "TestAgent.yaml",
["FOUNDRY_AGENT_ANSWER"] = "QuestionAgent.yaml",
["FOUNDRY_AGENT_STUDENT"] = "StudentAgent.yaml",
["FOUNDRY_AGENT_TEACHER"] = "TeacherAgent.yaml",
["FOUNDRY_AGENT_RESEARCHANALYST"] = "AnalystAgent.yaml",
["FOUNDRY_AGENT_RESEARCHCODER"] = "CoderAgent.yaml",
["FOUNDRY_AGENT_RESEARCHMANAGER"] = "ManagerAgent.yaml",
["FOUNDRY_AGENT_RESEARCHWEATHER"] = "WeatherAgent.yaml",
["FOUNDRY_AGENT_RESEARCHWEB"] = "WebAgent.yaml",
};
Agent? agent = await factory.CreateAgentFromYamlAsync(agentText, new AgentCreationOptions() { Kernel = kernel }, configuration: null, cancellationToken);
private static FrozenDictionary<string, string?>? s_agentMap;
Assert.NotNull(agent?.Name);
Debug.WriteLine($"TEST AGENT: {agent.Name} => {agent.Id}");
agentMap[agent.Name] = agent.Id;
public static async Task<FrozenDictionary<string, string?>> GetAgentsAsync(AzureAIConfiguration config, IConfiguration configuration, CancellationToken cancellationToken = default)
{
if (s_agentMap is not null)
{
return s_agentMap;
}
return agentMap;
PersistentAgentsClient clientAgents = new(config.Endpoint, new AzureCliCredential());
AIProjectClient clientProjects = new(new Uri(config.Endpoint), new AzureCliCredential());
IKernelBuilder kernelBuilder = Kernel.CreateBuilder();
kernelBuilder.Services.AddSingleton(clientAgents);
kernelBuilder.Services.AddSingleton(clientProjects);
AgentCreationOptions creationOptions = new() { Kernel = kernelBuilder.Build() };
AzureAIAgentFactory factory = new();
string repoRoot = WorkflowTest.GetRepoFolder();
return s_agentMap = (await Task.WhenAll(_agentDefinitions.Select(kvp => CreateAgentAsync(kvp.Key, kvp.Value, cancellationToken)))).ToFrozenDictionary(t => t.Name, t => t.Id);
async Task<(string Name, string? Id)> CreateAgentAsync(string id, string file, CancellationToken cancellationToken)
{
try
{
string filePath = Path.Combine("Agents", file);
if (!File.Exists(filePath))
{
filePath = Path.Combine(repoRoot, "workflow-samples/setup", file);
}
Assert.True(File.Exists(filePath), $"Agent definition file not found: {file}");
Debug.WriteLine($"TEST AGENT: Creating - {file}");
string agentText = File.ReadAllText(filePath);
Agent? agent = await factory.CreateAgentFromYamlAsync(agentText, creationOptions, configuration, cancellationToken);
Assert.NotNull(agent?.Name);
Debug.WriteLine($"TEST AGENT: {agent.Name} => {agent.Id} [{id}]");
return (id, agent.Id);
}
catch (Exception exception)
{
Console.WriteLine($"FAILURE: Error creating agent {id} from file {file}: {exception.Message}");
throw;
}
}
}
}
@@ -1,20 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Shared.IntegrationTests;
namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework;
public static class AgentFixture
{
private static IReadOnlyDictionary<string, string?>? s_agentMap;
internal static async Task<IReadOnlyDictionary<string, string?>> GetAgentsAsync(AzureAIConfiguration config, CancellationToken cancellationToken = default)
{
s_agentMap ??= await AgentFactory.CreateAsync("Agents", config, cancellationToken);
return s_agentMap;
}
}
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Reflection;
using Microsoft.Agents.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.Configuration;
using Xunit.Abstractions;
namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework;
/// <summary>
/// Base class for workflow tests.
/// </summary>
public abstract class IntegrationTest : IDisposable
{
public TestOutputAdapter Output { get; }
protected IntegrationTest(ITestOutputHelper output)
{
this.Output = new TestOutputAdapter(output);
Console.SetOut(this.Output);
SetProduct();
}
public void Dispose()
{
this.Dispose(isDisposing: true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool isDisposing)
{
if (isDisposing)
{
this.Output.Dispose();
}
}
protected static void SetProduct()
{
if (!ProductContext.IsLocalScopeSupported())
{
ProductContext.SetContext(Product.Foundry);
}
}
internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? WorkflowFormulaState.DefaultScopeName}.{variableName}";
protected static IConfigurationRoot InitializeConfig() =>
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.Build();
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework;
@@ -50,10 +51,34 @@ public sealed class TestcaseInput
public sealed class TestcaseValidation
{
[JsonConstructor]
public TestcaseValidation(int actionCount)
public TestcaseValidation(int minActionCount, int? maxActionCount = null, TestcaseValidationActions? actions = null)
{
this.ActionCount = actionCount;
this.MinActionCount = minActionCount;
this.MaxActionCount = maxActionCount;
this.Actions = actions ?? new TestcaseValidationActions([]);
}
public int ActionCount { get; }
public TestcaseValidationActions Actions { get; }
public int MinActionCount { get; }
public int? MaxActionCount { get; }
}
public sealed class TestcaseValidationActions
{
[JsonConstructor]
public TestcaseValidationActions(IList<string> start, IList<string>? repeat = null, IList<string>? final = null)
{
this.Start = start;
this.Repeat = repeat ?? [];
this.Final = final ?? [];
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public IList<string> Start { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public IList<string> Repeat { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public IList<string> Final { get; }
}
@@ -14,10 +14,14 @@ internal sealed class WorkflowEvents
this.EventCounts = workflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count());
this.ActionInvokeEvents = workflowEvents.OfType<DeclarativeActionInvokedEvent>().ToList();
this.ActionCompleteEvents = workflowEvents.OfType<DeclarativeActionCompletedEvent>().ToList();
this.ExecutorInvokeEvents = workflowEvents.OfType<ExecutorInvokedEvent>().ToList();
this.ExecutorCompleteEvents = workflowEvents.OfType<ExecutorCompletedEvent>().ToList();
}
public IReadOnlyList<WorkflowEvent> Events { get; }
public IReadOnlyDictionary<Type, int> EventCounts { get; }
public IReadOnlyList<DeclarativeActionInvokedEvent> ActionInvokeEvents { get; }
public IReadOnlyList<DeclarativeActionCompletedEvent> ActionCompleteEvents { get; }
public IReadOnlyList<ExecutorInvokedEvent> ExecutorInvokeEvents { get; }
public IReadOnlyList<ExecutorCompletedEvent> ExecutorCompleteEvents { get; }
}
@@ -1,45 +1,172 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Reflection;
using Microsoft.Agents.Workflows.Declarative.PowerFx;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Shared.IntegrationTests;
using Xunit.Abstractions;
using Xunit.Sdk;
namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework;
/// <summary>
/// Base class for workflow tests.
/// </summary>
public abstract class WorkflowTest : IDisposable
public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(output)
{
public TestOutputAdapter Output { get; }
protected abstract Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions) where TInput : notnull;
protected WorkflowTest(ITestOutputHelper output)
protected Task RunWorkflowAsync(string workflowPath, string testcaseFileName)
{
this.Output = new TestOutputAdapter(output);
Console.SetOut(this.Output);
this.Output.WriteLine($"WORKFLOW: {workflowPath}");
this.Output.WriteLine($"TESTCASE: {testcaseFileName}");
Testcase testcase = ReadTestcase(testcaseFileName);
IConfiguration configuration = InitializeConfig();
this.Output.WriteLine($" {testcase.Description}");
return
testcase.Setup.Input.Type switch
{
nameof(ChatMessage) => this.TestWorkflowAsync<ChatMessage>(testcase, workflowPath, configuration),
nameof(String) => this.TestWorkflowAsync<string>(testcase, workflowPath, configuration),
_ => throw new NotSupportedException($"Input type '{testcase.Setup.Input.Type}' is not supported."),
};
}
public void Dispose()
protected async Task TestWorkflowAsync<TInput>(
Testcase testcase,
string workflowPath,
IConfiguration configuration) where TInput : notnull
{
this.Dispose(isDisposing: true);
GC.SuppressFinalize(this);
this.Output.WriteLine($"INPUT: {testcase.Setup.Input.Value}");
AzureAIConfiguration? foundryConfig = configuration.GetSection("AzureAI").Get<AzureAIConfiguration>();
Assert.NotNull(foundryConfig);
FrozenDictionary<string, string?> agentMap = await AgentFactory.GetAgentsAsync(foundryConfig, configuration);
IConfiguration workflowConfig =
new ConfigurationBuilder()
.AddInMemoryCollection(agentMap)
.Build();
DeclarativeWorkflowOptions workflowOptions =
new(new AzureAgentProvider(foundryConfig.Endpoint, new AzureCliCredential()))
{
Configuration = workflowConfig,
LoggerFactory = this.Output
};
await this.RunAndVerifyAsync<TInput>(testcase, workflowPath, workflowOptions);
}
protected virtual void Dispose(bool isDisposing)
{
if (isDisposing)
protected static object GetInput<TInput>(Testcase testcase) where TInput : notnull =>
testcase.Setup.Input.Type switch
{
this.Output.Dispose();
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."),
};
protected 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;
}
internal static string GetRepoFolder()
{
DirectoryInfo? current = new(Directory.GetCurrentDirectory());
while (current is not null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
return current.FullName;
}
current = current.Parent;
}
throw new XunitException("Unable to locate repository root folder.");
}
protected static class AssertWorkflow
{
public static void EventCounts(int actualCount, Testcase testcase)
{
Assert.True(actualCount >= testcase.Validation.MinActionCount, $"Event count less than expected: {testcase.Validation.MinActionCount} ({actualCount}).");
Assert.True(actualCount <= (testcase.Validation.MaxActionCount ?? testcase.Validation.MinActionCount), $"Event count greater than expected: {testcase.Validation.MaxActionCount ?? testcase.Validation.MinActionCount} ({actualCount}).");
}
internal static void EventSequence(IEnumerable<string> sourceIds, Testcase testcase)
{
string lastId = string.Empty;
Queue<string> startIds = [];
Queue<string> repeatIds = [];
bool validateStart = false;
bool validateRepeat = false;
foreach (string sourceId in sourceIds)
{
if (!validateStart)
{
if (testcase.Validation.Actions.Start.Count > 0 &&
startIds.Count == 0 &&
sourceId.Equals(testcase.Validation.Actions.Start[0], StringComparison.Ordinal))
{
// Initialize start sequence
startIds = new(testcase.Validation.Actions.Start);
}
// Verify start sequence
if (startIds.Count > 0)
{
Assert.Equal(startIds.Dequeue(), sourceId);
validateStart = startIds.Count == 0;
}
}
else
{
if (testcase.Validation.Actions.Repeat.Count > 0 &&
repeatIds.Count == 0 &&
sourceId.Equals(testcase.Validation.Actions.Repeat[0], StringComparison.Ordinal))
{
// Initialize repeat sequence
repeatIds = new(testcase.Validation.Actions.Repeat);
}
// Verify repeat sequence
if (repeatIds.Count > 0)
{
Assert.Equal(repeatIds.Dequeue(), sourceId);
validateRepeat = true;
}
}
lastId = sourceId;
}
Assert.Equal(testcase.Validation.Actions.Start.Count > 0, validateStart);
Assert.Equal(testcase.Validation.Actions.Repeat.Count > 0, validateRepeat);
Assert.NotEmpty(lastId);
HashSet<string> finalIds = [.. testcase.Validation.Actions.Final];
Assert.Contains(lastId, finalIds);
}
}
internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? WorkflowFormulaState.DefaultScopeName}.{variableName}";
protected static IConfigurationRoot InitializeConfig() =>
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
protected static readonly JsonSerializerOptions s_jsonSerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = true,
};
}
@@ -0,0 +1,26 @@
{
"description": "Create conversation and manipulate messages.",
"setup": {
"input": {
"type": "String",
"value": "Why is the sky blue?"
}
},
"validation": {
"min_action_count": 7,
"actions": {
"start": [
"conversation_create1",
"conversation_create2",
"sendActivity_conversation",
"add_message",
"sendActivity_message",
"copy_messages",
"sendActivity_copy"
],
"final": [
"sendActivity_copy"
]
}
}
}
@@ -0,0 +1,40 @@
{
"description": "Planned orchestration sample - DeepResearch.yaml.",
"setup": {
"input": {
"type": "String",
"value": "What is the closest bus-stop that is next to ISHONI YAKINIKU in Seattle?"
}
},
"validation": {
"min_action_count": 28,
"max_action_count": 56,
"actions": {
"start": [
"setVariable_aASlmF",
"setVariable_V6yEbo",
"setVariable_NZ2u0l",
"setVariable_10u2ZN",
"sendActivity_yFsbRy",
"conversation_1a2b3c",
"question_UDoMUw",
"sendActivity_yFsbRz",
"question_DsBaJU",
"setVariable_Kk2LDL",
"sendActivity_bwNZiM",
"question_o3BQkf",
"parse_rNZtlV",
"conditionGroup_mVIecC"
],
"repeat": [
"question_o3BQkf",
"parse_rNZtlV",
"conditionGroup_mVIecC"
],
"final": [
"end_SVoNSV",
"end_GHVrFh"
]
}
}
}
@@ -0,0 +1,20 @@
{
"description": "Human in the loop sample - HumanInLoop.yaml.",
"setup": {
"input": {
"type": "String",
"value": "Iko"
}
},
"validation": {
"min_action_count": 1,
"actions": {
"start": [
"invoke_agent"
],
"final": [
"invoke_agent"
]
}
}
}
@@ -7,6 +7,14 @@
}
},
"validation": {
"action_count": 1
"min_action_count": 1,
"actions": {
"start": [
"invoke_agent"
],
"final": [
"invoke_agent"
]
}
}
}
@@ -0,0 +1,23 @@
{
"description": "Sequential agent invocation sample - Marketing.yaml.",
"setup": {
"input": {
"type": "String",
"value": "An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours."
}
},
"validation": {
"min_action_count": 4,
"actions": {
"start": [
"add_input_message",
"invoke_analyst",
"invoke_writer",
"invoke_editor"
],
"final": [
"invoke_editor"
]
}
}
}
@@ -0,0 +1,29 @@
{
"description": "Student/Teacher sample - MathChat.yaml.",
"setup": {
"input": {
"type": "String",
"value": "How could one compute the value of PI?"
}
},
"validation": {
"min_action_count": 6,
"max_action_count": 25,
"actions": {
"start": [
"set_project"
],
"repeat": [
"question_student",
"reset_project",
"question_teacher",
"set_count_increment",
"check_completion"
],
"final": [
"sendActivity_done",
"sendActivity_tired"
]
}
}
}
@@ -1,5 +1,5 @@
{
"description": "Send an activity message .",
"description": "Send an activity message.",
"setup": {
"input": {
"type": "String",
@@ -7,6 +7,16 @@
}
},
"validation": {
"action_count": 3
"min_action_count": 3,
"actions": {
"start": [
"set_user_input",
"set_user_name",
"send_result"
],
"final": [
"send_result"
]
}
}
}
@@ -8,7 +8,7 @@ trigger:
- kind: InvokeAzureAgent
id: invoke_agent
agent:
name: =Env.BasicAgent
name: =Env.FOUNDRY_AGENT_TEST
input:
messages: =[UserMessage(System.LastMessageText)]
output:
@@ -4,10 +4,12 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.Workflows.Declarative.Interpreter;
using Microsoft.Agents.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.AI;
using Moq;
using Xunit.Abstractions;
@@ -36,8 +38,10 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
public async Task LoopEachActionAsync()
{
await this.RunWorkflowAsync("LoopEach.yaml");
this.AssertExecutionCount(expectedCount: 35);
this.AssertExecutionCount(expectedCount: 34);
this.AssertExecuted("foreach_loop");
this.AssertExecuted("set_variable_inner");
this.AssertExecuted("send_activity_inner");
this.AssertExecuted("end_all");
}
@@ -45,24 +49,24 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
public async Task LoopBreakActionAsync()
{
await this.RunWorkflowAsync("LoopBreak.yaml");
this.AssertExecutionCount(expectedCount: 7);
this.AssertExecutionCount(expectedCount: 6);
this.AssertExecuted("foreach_loop");
this.AssertExecuted("breakLoop_now");
this.AssertExecuted("break_loop_now");
this.AssertExecuted("end_all");
this.AssertNotExecuted("setVariable_loop");
this.AssertNotExecuted("sendActivity_loop");
this.AssertNotExecuted("set_variable_inner");
this.AssertNotExecuted("send_activity_inner");
}
[Fact]
public async Task LoopContinueActionAsync()
{
await this.RunWorkflowAsync("LoopContinue.yaml");
this.AssertExecutionCount(expectedCount: 23);
this.AssertExecutionCount(expectedCount: 22);
this.AssertExecuted("foreach_loop");
this.AssertExecuted("continueLoop_now");
this.AssertExecuted("continue_loop_now");
this.AssertExecuted("end_all");
this.AssertNotExecuted("setVariable_loop");
this.AssertNotExecuted("sendActivity_loop");
this.AssertNotExecuted("set_variable_inner");
this.AssertNotExecuted("send_activity_inner");
}
[Fact]
@@ -209,9 +213,9 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
AdaptiveDialog dialog = dialogBuilder.Build();
WorkflowFormulaState state = new(RecalcEngineFactory.Create());
Mock<WorkflowAgentProvider> mockAgentProvider = new(MockBehavior.Strict);
Mock<WorkflowAgentProvider> mockAgentProvider = CreateMockProvider();
DeclarativeWorkflowOptions options = new(mockAgentProvider.Object);
WorkflowActionVisitor visitor = new(new DeclarativeWorkflowExecutor<string>(WorkflowActionVisitor.Steps.Root("anything"), state, (message) => DeclarativeWorkflowBuilder.DefaultTransform(message)), state, options);
WorkflowActionVisitor visitor = new(new DeclarativeWorkflowExecutor<string>(WorkflowActionVisitor.Steps.Root("anything"), mockAgentProvider.Object, state, (message) => DeclarativeWorkflowBuilder.DefaultTransform(message)), state, options);
WorkflowElementWalker walker = new(visitor);
walker.Visit(dialog);
Assert.True(visitor.HasUnsupportedActions);
@@ -249,7 +253,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
private async Task RunWorkflowAsync<TInput>(string workflowPath, TInput workflowInput) where TInput : notnull
{
using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath));
Mock<WorkflowAgentProvider> mockAgentProvider = new(MockBehavior.Strict);
Mock<WorkflowAgentProvider> mockAgentProvider = CreateMockProvider();
DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output };
Workflow workflow = DeclarativeWorkflowBuilder.Build<TInput>(yamlReader, workflowContext);
@@ -261,7 +265,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
{
if (workflowEvent is ExecutorInvokedEvent invokeEvent)
{
ExecutorResultMessage? message = invokeEvent.Data as ExecutorResultMessage;
ActionExecutorResult? message = invokeEvent.Data as ActionExecutorResult;
this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]");
}
else if (workflowEvent is DeclarativeActionInvokedEvent actionInvokeEvent)
@@ -283,4 +287,12 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
}
this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count());
}
private static Mock<WorkflowAgentProvider> CreateMockProvider()
{
Mock<WorkflowAgentProvider> mockAgentProvider = new(MockBehavior.Strict);
mockAgentProvider.Setup(provider => provider.CreateConversationAsync(It.IsAny<CancellationToken>())).Returns(() => Task.FromResult(Guid.NewGuid().ToString("N")));
mockAgentProvider.Setup(provider => provider.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
return mockAgentProvider;
}
}
@@ -73,6 +73,6 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor
IMessageHandler<WorkflowFormulaState>
{
public async ValueTask HandleAsync(WorkflowFormulaState message, IWorkflowContext context) =>
await context.SendMessageAsync(new ExecutorResultMessage(this.Id)).ConfigureAwait(false);
await context.SendMessageAsync(new ActionExecutorResult(this.Id)).ConfigureAwait(false);
}
}
@@ -2,6 +2,7 @@
using System;
using Microsoft.Agents.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Xunit.Abstractions;
namespace Microsoft.Agents.Workflows.Declarative.UnitTests;
@@ -17,6 +18,7 @@ public abstract class WorkflowTest : IDisposable
{
this.Output = new TestOutputAdapter(output);
Console.SetOut(this.Output);
SetProduct();
}
public void Dispose()
@@ -33,6 +35,14 @@ public abstract class WorkflowTest : IDisposable
}
}
protected static void SetProduct()
{
if (!ProductContext.IsLocalScopeSupported())
{
ProductContext.SetContext(Product.Foundry);
}
}
internal static string? FormatOptionalPath(string? variableName, string? scope = null) =>
variableName is null ? null : FormatVariablePath(variableName, scope);
@@ -5,25 +5,23 @@ trigger:
id: my_workflow
actions:
- kind: SetVariable
id: setVariable_count
variable: Local.Count
value: =0
- kind: Foreach
id: foreach_loop
items: =["a", "b", "c", "d", "e", "f"]
index: Local.LoopIndex
value: Local.LoopValue
actions:
- kind: BreakLoop
id: breakLoop_now
id: break_loop_now
- kind: SetVariable
id: setVariable_loop
id: set_variable_inner
variable: Local.Count
value: =Local.Count + 1
- kind: SendActivity
id: sendActivity_loop
id: send_activity_inner
activity: x{Local.Count} - {Local.LoopIndex}:{Local.LoopValue}
- kind: EndConversation
@@ -3,12 +3,7 @@ trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: SetVariable
id: setVariable_count
variable: Local.Count
value: =0
- kind: Foreach
id: foreach_loop
@@ -16,14 +11,17 @@ trigger:
index: Local.LoopIndex
value: Local.LoopValue
actions:
- kind: ContinueLoop
id: continueLoop_now
id: continue_loop_now
- kind: SetVariable
id: setVariable_loop
id: set_variable_inner
variable: Local.Count
value: =Local.Count + 1
- kind: SendActivity
id: sendActivity_loop
id: send_activity_inner
activity: x{Local.Count} - {Local.LoopIndex}:{Local.LoopValue}
- kind: EndConversation
@@ -3,12 +3,7 @@ trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: SetVariable
id: setVariable_count
variable: Local.Count
value: =0
- kind: Foreach
id: foreach_loop
@@ -16,12 +11,14 @@ trigger:
index: Local.LoopIndex
value: Local.LoopValue
actions:
- kind: SetVariable
id: setVariable_loop
id: set_variable_inner
variable: Local.Count
value: =Local.Count + 1
- kind: SendActivity
id: sendActivity_loop
id: send_activity_inner
activity: x{Local.Count} - {Local.LoopIndex}:{Local.LoopValue}
- kind: EndConversation
+12 -14
View File
@@ -75,7 +75,7 @@ trigger:
id: setVariable_10u2ZN
displayName: Set Task
variable: Local.SeedTask
value: =Local.InputTask
value: =UserMessage(Local.InputTask)
- kind: SendActivity
id: sendActivity_yFsbRy
@@ -94,7 +94,7 @@ trigger:
output:
messages: Local.TaskFacts
input:
messages: =[UserMessage(Local.InputTask)]
messages: =UserMessage(Local.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.
@@ -128,7 +128,7 @@ trigger:
output:
messages: Local.Plan
input:
messages: =[UserMessage(Local.InputTask)]
messages: =UserMessage(Local.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.
@@ -160,13 +160,13 @@ trigger:
# FACTS
Consider this initial fact sheet:
" & Trim(Local.TaskFacts.Text) & "
" & Trim(Last(Local.TaskFacts).Text) & "
# PLAN
Here is the plan to follow as best as possible:
" & Local.Plan.Text
" & Last(Local.Plan).Text
- kind: SendActivity
id: sendActivity_bwNZiM
@@ -181,7 +181,7 @@ trigger:
output:
messages: Local.ProgressLedgerUpdate
input:
messages: =[UserMessage(Local.AgentResponseText)]
messages: =UserMessage(Local.AgentResponseText)
additionalInstructions: |-
Recall we are working on the following request:
@@ -228,7 +228,7 @@ trigger:
id: parse_rNZtlV
displayName: Parse ledger response
variable: Local.TypedProgressLedger
value: =Local.ProgressLedgerUpdate.Text
value: =Last(Local.ProgressLedgerUpdate).Text
valueType:
kind: Record
properties:
@@ -288,7 +288,7 @@ trigger:
output:
messages: Local.FinalResponse
input:
messages: =[UserMessage(Local.SeedTask)]
messages: =Local.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.
@@ -369,12 +369,10 @@ trigger:
messages: Local.TaskFacts
input:
messages: |-
=[
UserMessage(
=UserMessage(
"As a reminder, we are working to solve the following task:
" & Local.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.
@@ -486,14 +484,14 @@ trigger:
output:
messages: Local.AgentResponse
input:
messages: =[UserMessage(Local.SeedTask)]
messages: =Local.SeedTask
additionalInstructions: |-
{Local.TypedProgressLedger.instruction_or_question.answer}
- kind: SetVariable
id: setVariable_XzNrdM
variable: Local.AgentResponseText
value: =Local.AgentResponse.Text
value: =Last(Local.AgentResponse).Text
- kind: ResetVariable
id: setVariable_8eIx2A
+5 -5
View File
@@ -33,8 +33,8 @@ trigger:
- kind: SetVariable
id: set_project
variable: Local.Project
value: =System.LastMessage.Text
variable: Local.InputTask
value: =UserMessage(System.LastMessageText)
- kind: InvokeAzureAgent
id: question_student
@@ -42,11 +42,11 @@ trigger:
agent:
name: =Env.FOUNDRY_AGENT_STUDENT
input:
messages: =[UserMessage(Local.Project)]
messages: =Local.InputTask
- kind: ResetVariable
id: reset_project
variable: Local.Project
variable: Local.InputTask
- kind: InvokeAzureAgent
id: question_teacher
@@ -65,7 +65,7 @@ trigger:
id: check_completion
conditions:
- condition: =!IsBlank(Find("CONGRATULATIONS", Upper(Local.TeacherResponse.Text)))
- condition: =!IsBlank(Find("CONGRATULATIONS", Upper(Last(Local.TeacherResponse).Text)))
id: check_turn_done
actions: