mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
@@ -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();
|
||||
}
|
||||
|
||||
+62
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-18
@@ -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());
|
||||
}
|
||||
|
||||
+37
-4
@@ -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
-21
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -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)
|
||||
{
|
||||
|
||||
+27
-8
@@ -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);
|
||||
|
||||
+10
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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)"}");
|
||||
|
||||
|
||||
-16
@@ -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;
|
||||
}
|
||||
}
|
||||
+91
-60
@@ -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);
|
||||
|
||||
-2
@@ -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() };
|
||||
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
|
||||
+5
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-46
@@ -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);
|
||||
}
|
||||
|
||||
-1
@@ -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;
|
||||
|
||||
|
||||
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ type: foundry_agent
|
||||
name: BasicAgent
|
||||
description: Basic agent for integration tests
|
||||
model:
|
||||
id: ${AzureAI:ModelDeployment}
|
||||
id: ${AzureAI:DeploymentMini}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+18
-77
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+62
-21
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-20
@@ -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;
|
||||
}
|
||||
}
|
||||
+54
@@ -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();
|
||||
}
|
||||
+28
-3
@@ -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; }
|
||||
}
|
||||
|
||||
+4
@@ -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; }
|
||||
}
|
||||
|
||||
+148
-21
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+26
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -7,6 +7,14 @@
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"action_count": 1
|
||||
"min_action_count": 1,
|
||||
"actions": {
|
||||
"start": [
|
||||
"invoke_agent"
|
||||
],
|
||||
"final": [
|
||||
"invoke_agent"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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:
|
||||
|
||||
+25
-13
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
+6
-8
@@ -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
|
||||
|
||||
+6
-8
@@ -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
|
||||
|
||||
+4
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user