.NET Workflows - Enhance support and validation for image input (#1274)

* Updated

* Comments

* Namespace

* Comment

* Cleanup

* Update agent definition

* Disable

* Config

* Enable
This commit is contained in:
Chris
2025-10-07 17:00:34 -07:00
committed by GitHub
Unverified
parent 362652b966
commit eb049c43a6
17 changed files with 339 additions and 189 deletions
@@ -160,6 +160,7 @@ jobs:
AzureAI__DeploymentName: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
AzureAI__BingConnectionId: ${{ vars.AZUREAI__BINGCONECTIONID }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MEDIA_DEPLOYMENT_NAME: ${{ vars.FOUNDRY_MEDIA_DEPLOYMENT_NAME }}
FOUNDRY_MODEL_DEPLOYMENT_NAME: ${{ vars.FOUNDRY_MODEL_DEPLOYMENT_NAME }}
FOUNDRY_CONNECTION_GROUNDING_TOOL: ${{ vars.FOUNDRY_CONNECTION_GROUNDING_TOOL }}
@@ -76,102 +76,103 @@ internal sealed class Program
string? messageId = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is ExecutorInvokedEvent executorInvoked)
switch (workflowEvent)
{
Debug.WriteLine($"STEP ENTER #{executorInvoked.ExecutorId}");
}
else if (evt is ExecutorCompletedEvent executorComplete)
{
Debug.WriteLine($"STEP EXIT #{executorComplete.ExecutorId}");
}
else if (evt is ExecutorFailedEvent executorFailure)
{
Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}");
}
else if (evt is WorkflowErrorEvent workflowError)
{
Debug.WriteLine("WORKFLOW ERROR");
}
else if (evt is ConversationUpdateEvent invokeEvent)
{
Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}");
}
else if (evt is AgentRunUpdateEvent streamEvent)
{
if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal))
{
messageId = streamEvent.Update.MessageId;
case ExecutorInvokedEvent executorInvoked:
Debug.WriteLine($"STEP ENTER #{executorInvoked.ExecutorId}");
break;
if (messageId is not null)
case ExecutorCompletedEvent executorComplete:
Debug.WriteLine($"STEP EXIT #{executorComplete.ExecutorId}");
break;
case ExecutorFailedEvent executorFailure:
Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}");
break;
case WorkflowErrorEvent workflowError:
throw workflowError.Data as Exception ?? new InvalidOperationException("Unexpected failure...");
case ConversationUpdateEvent invokeEvent:
Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}");
break;
case AgentRunUpdateEvent streamEvent:
if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal))
{
string? agentId = streamEvent.Update.AuthorName;
if (agentId is not null)
messageId = streamEvent.Update.MessageId;
if (messageId is not null)
{
if (!s_nameCache.TryGetValue(agentId, out string? realName))
string? agentId = streamEvent.Update.AuthorName;
if (agentId is not null)
{
PersistentAgent agent = await this.FoundryClient.Administration.GetAgentAsync(agentId);
s_nameCache[agentId] = agent.Name;
realName = agent.Name;
if (!s_nameCache.TryGetValue(agentId, out string? realName))
{
PersistentAgent agent = await this.FoundryClient.Administration.GetAgentAsync(agentId);
s_nameCache[agentId] = agent.Name;
realName = agent.Name;
}
agentId = realName;
}
agentId = realName;
}
agentId ??= nameof(ChatRole.Assistant);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write($"\n{agentId.ToUpperInvariant()}:");
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" [{messageId}]");
}
}
ChatResponseUpdate? chatUpdate = streamEvent.Update.RawRepresentation as ChatResponseUpdate;
switch (chatUpdate?.RawRepresentation)
{
case MessageContentUpdate messageUpdate:
string? fileId = messageUpdate.ImageFileId ?? messageUpdate.TextAnnotation?.OutputFileId;
if (fileId is not null && s_fileCache.Add(fileId))
{
BinaryData content = await this.FoundryClient.Files.GetFileContentAsync(fileId);
await DownloadFileContentAsync(Path.GetFileName(messageUpdate.TextAnnotation?.TextToReplace ?? "response.png"), content);
}
break;
}
try
{
Console.ResetColor();
Console.Write(streamEvent.Data);
}
finally
{
Console.ResetColor();
}
}
else if (evt is AgentRunResponseEvent messageEvent)
{
try
{
Console.WriteLine();
if (messageEvent.Response.AgentId is null)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("ACTIVITY:");
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(messageEvent.Response?.Text.Trim());
}
else
{
if (messageEvent.Response.Usage is not null)
{
agentId ??= nameof(ChatRole.Assistant);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write($"\n{agentId.ToUpperInvariant()}:");
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"[Tokens Total: {messageEvent.Response.Usage.TotalTokenCount}, Input: {messageEvent.Response.Usage.InputTokenCount}, Output: {messageEvent.Response.Usage.OutputTokenCount}]");
Console.WriteLine($" [{messageId}]");
}
}
}
finally
{
Console.ResetColor();
}
ChatResponseUpdate? chatUpdate = streamEvent.Update.RawRepresentation as ChatResponseUpdate;
switch (chatUpdate?.RawRepresentation)
{
case MessageContentUpdate messageUpdate:
string? fileId = messageUpdate.ImageFileId ?? messageUpdate.TextAnnotation?.OutputFileId;
if (fileId is not null && s_fileCache.Add(fileId))
{
BinaryData content = await this.FoundryClient.Files.GetFileContentAsync(fileId);
await DownloadFileContentAsync(Path.GetFileName(messageUpdate.TextAnnotation?.TextToReplace ?? "response.png"), content);
}
break;
}
try
{
Console.ResetColor();
Console.Write(streamEvent.Data);
}
finally
{
Console.ResetColor();
}
break;
case AgentRunResponseEvent messageEvent:
try
{
Console.WriteLine();
if (messageEvent.Response.AgentId is null)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("ACTIVITY:");
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(messageEvent.Response?.Text.Trim());
}
else
{
if (messageEvent.Response.Usage is not null)
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"[Tokens Total: {messageEvent.Response.Usage.TotalTokenCount}, Input: {messageEvent.Response.Usage.InputTokenCount}, Output: {messageEvent.Response.Usage.OutputTokenCount}]");
}
}
}
finally
{
Console.ResetColor();
}
break;
}
}
}
@@ -163,6 +163,9 @@ internal sealed class Program
Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}");
break;
case WorkflowErrorEvent workflowError:
throw workflowError.Data as Exception ?? new InvalidOperationException("Unexpected failure...");
case SuperStepCompletedEvent checkpointCompleted:
this.LastCheckpoint = checkpointCompleted.CompletionInfo?.Checkpoint;
Debug.WriteLine($"CHECKPOINT x{checkpointCompleted.StepNumber} [{this.LastCheckpoint?.CheckpointId ?? "(none)"}]");
@@ -78,6 +78,7 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p
TextContent textContent => new MessageInputTextBlock(textContent.Text),
HostedFileContent fileContent => new MessageInputImageFileBlock(new MessageImageFileParam(fileContent.FileId)),
UriContent uriContent when uriContent.Uri is not null => new MessageInputImageUriBlock(new MessageImageUriParam(uriContent.Uri.ToString())),
DataContent dataContent when dataContent.Uri is not null => new MessageInputImageUriBlock(new MessageImageUriParam(dataContent.Uri)),
_ => null // Unsupported content type
};
@@ -4,12 +4,21 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Agents.Persistent;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
internal static class AgentProviderExtensions
{
private static readonly HashSet<Azure.AI.Agents.Persistent.RunStatus> s_failureStatus =
[
Azure.AI.Agents.Persistent.RunStatus.Failed,
Azure.AI.Agents.Persistent.RunStatus.Cancelled,
Azure.AI.Agents.Persistent.RunStatus.Cancelling,
Azure.AI.Agents.Persistent.RunStatus.Expired,
];
public static async ValueTask<AgentRunResponse> InvokeAgentAsync(
this WorkflowAgentProvider agentProvider,
string executorId,
@@ -51,6 +60,13 @@ internal static class AgentProviderExtensions
updates.Add(update);
if (update.RawRepresentation is ChatResponseUpdate chatUpdate &&
chatUpdate.RawRepresentation is RunUpdate runUpdate &&
s_failureStatus.Contains(runUpdate.Value.Status))
{
throw new DeclarativeActionException($"Unexpected failure invoking agent, run {runUpdate.Value.Status}: {agent.Name ?? agent.Id} [{runUpdate.Value.Id}/{conversationId}]");
}
if (autoSend)
{
await context.AddEventAsync(new AgentRunUpdateEvent(executorId, update)).ConfigureAwait(false);
@@ -131,7 +131,7 @@ internal static class ChatMessageExtensions
return
contentType switch
{
AgentMessageContentType.ImageUrl => new UriContent(contentValue, "image/*"),
AgentMessageContentType.ImageUrl => GetImageContent(contentValue),
AgentMessageContentType.ImageFile => new HostedFileContent(contentValue),
_ => new TextContent(contentValue)
};
@@ -169,7 +169,7 @@ internal static class ChatMessageExtensions
yield return
contentItem?.GetProperty<StringDataValue>(TypeSchema.Message.Fields.ContentType)?.Value switch
{
TypeSchema.Message.ContentTypes.ImageUrl => new UriContent(contentValue.Value, "image/*"),
TypeSchema.Message.ContentTypes.ImageUrl => GetImageContent(contentValue.Value),
TypeSchema.Message.ContentTypes.ImageFile => new HostedFileContent(contentValue.Value),
_ => new TextContent(contentValue.Value)
};
@@ -177,6 +177,11 @@ internal static class ChatMessageExtensions
}
}
private static AIContent GetImageContent(string uriText) =>
uriText.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ?
new DataContent(uriText, "image/*") :
new UriContent(uriText, "image/*");
private static TValue? GetProperty<TValue>(this RecordDataValue record, string name)
where TValue : DataValue
{
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
@@ -21,16 +21,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Agents.Persistent" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Bot.ObjectModel" />
<PackageReference Include="Microsoft.Bot.ObjectModel.Json" />
<PackageReference Include="Microsoft.Bot.ObjectModel.PowerFx" />
<PackageReference Include="Microsoft.PowerFx.Interpreter" />
<PackageReference Include="System.CodeDom" />
<PackageReference Include="System.Collections.Immutable" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="System.CodeDom" />
<PackageReference Include="System.Collections.Immutable" />
</ItemGroup>
<ItemGroup>
@@ -2,4 +2,4 @@ type: foundry_agent
name: BasicAgent
description: Basic agent for integration tests
model:
id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME}
id: ${FOUNDRY_MEDIA_DEPLOYMENT_NAME}
@@ -8,21 +8,17 @@ using Azure.AI.Agents.Persistent;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Shared.IntegrationTests;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
public sealed class AzureAgentProviderTest(ITestOutputHelper output) : IntegrationTest(output)
{
private AzureAIConfiguration? _configuration;
[Fact]
public async Task ConversationTestAsync()
{
// Arrange
AzureAgentProvider provider = new(this.Configuration.Endpoint, new AzureCliCredential());
AzureAgentProvider provider = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential());
// Act
string conversationId = await provider.CreateConversationAsync();
// Assert
@@ -52,7 +48,7 @@ public sealed class AzureAgentProviderTest(ITestOutputHelper output) : Integrati
public async Task GetAgentTestAsync()
{
// Arrange
AzureAgentProvider provider = new(this.Configuration.Endpoint, new AzureCliCredential());
AzureAgentProvider provider = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential());
string agentName = $"TestAgent-{DateTime.UtcNow:yyMMdd-HHmmss-fff}";
string agent1Id = await this.CreateAgentAsync();
@@ -74,22 +70,8 @@ public sealed class AzureAgentProviderTest(ITestOutputHelper output) : Integrati
private async ValueTask<string> CreateAgentAsync(string? name = null)
{
PersistentAgentsClient client = new(this.Configuration.Endpoint, new AzureCliCredential());
PersistentAgent agent = await client.Administration.CreateAgentAsync(this.Configuration.DeploymentName, name: name);
PersistentAgentsClient client = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential());
PersistentAgent agent = await client.Administration.CreateAgentAsync(this.FoundryConfiguration.DeploymentName, name: name);
return agent.Id;
}
private AzureAIConfiguration Configuration
{
get
{
if (this._configuration is null)
{
this._configuration ??= InitializeConfig().GetSection("AzureAI").Get<AzureAIConfiguration>();
Assert.NotNull(this._configuration);
}
return this._configuration;
}
}
}
@@ -12,7 +12,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
/// <summary>
/// Tests execution of workflow created by <see cref="DeclarativeWorkflowBuilder"/>.
/// </summary>
[Collection("Global")]
public sealed class DeclarativeCodeGenTest(ITestOutputHelper output) : WorkflowTest(output)
{
[Theory]
@@ -33,7 +32,7 @@ public sealed class DeclarativeCodeGenTest(ITestOutputHelper output) : WorkflowT
public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) =>
this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName), testcaseFileName, externalConveration);
protected override async Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions)
protected override async Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input)
{
const string WorkflowNamespace = "Test.WorkflowProviders";
const string WorkflowPrefix = "Test";
@@ -47,15 +46,19 @@ public sealed class DeclarativeCodeGenTest(ITestOutputHelper output) : WorkflowT
workflowProviderName: $"{WorkflowPrefix}WorkflowProvider",
WorkflowNamespace,
workflowOptions,
(TInput)GetInput<TInput>(testcase));
input);
WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, (TInput)GetInput<TInput>(testcase)).ConfigureAwait(false);
WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, input).ConfigureAwait(false);
// Verify no action events are present
Assert.Empty(workflowEvents.ActionInvokeEvents);
Assert.Empty(workflowEvents.ActionCompleteEvents);
// Verify the associated conversations
AssertWorkflow.Conversation(workflowOptions.ConversationId, workflowEvents.ConversationEvents, testcase);
// Verify executor events
AssertWorkflow.EventCounts(workflowEvents.ExecutorInvokeEvents.Count - 2, testcase);
AssertWorkflow.EventCounts(workflowEvents.ExecutorCompleteEvents.Count - 2, testcase);
// Verify action sequences
AssertWorkflow.EventSequence(workflowEvents.ExecutorInvokeEvents.Select(e => e.ExecutorId), testcase);
}
finally
@@ -12,7 +12,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
/// <summary>
/// Tests execution of workflow created by <see cref="DeclarativeWorkflowBuilder"/>.
/// </summary>
[Collection("Global")]
public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : WorkflowTest(output)
{
[Theory]
@@ -33,23 +32,29 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) =>
this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName), testcaseFileName, externalConveration);
protected override async Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions)
protected override async Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input)
{
Workflow workflow = DeclarativeWorkflowBuilder.Build<TInput>(workflowPath, workflowOptions);
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath));
WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, (TInput)GetInput<TInput>(testcase)).ConfigureAwait(false);
WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, input).ConfigureAwait(false);
// Verify executor events are present
Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents);
Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents);
// Verify the associated conversations
AssertWorkflow.Conversation(workflowOptions.ConversationId, workflowEvents.ConversationEvents, testcase);
// Verify the agent responses
AssertWorkflow.Responses(workflowEvents.AgentResponseEvents, testcase);
// Verify the messages on the workflow conversation
await AssertWorkflow.MessagesAsync(
GetConversationId(workflowOptions.ConversationId, workflowEvents.ConversationEvents),
testcase,
workflowOptions.AgentProvider);
// Verify action events
AssertWorkflow.EventCounts(workflowEvents.ActionInvokeEvents.Count, testcase);
AssertWorkflow.EventCounts(workflowEvents.ActionCompleteEvents.Count, testcase, isCompletion: true);
// Verify action sequences
AssertWorkflow.EventSequence(workflowEvents.ActionInvokeEvents.Select(e => e.ActionId), testcase);
}
}
@@ -1,10 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Frozen;
using System.Reflection;
using System.Threading.Tasks;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.Configuration;
using Shared.IntegrationTests;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
@@ -14,6 +18,21 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
/// </summary>
public abstract class IntegrationTest : IDisposable
{
private IConfigurationRoot? _configuration;
private AzureAIConfiguration? _foundryConfiguration;
protected IConfigurationRoot Configuration => this._configuration ??= InitializeConfig();
internal AzureAIConfiguration FoundryConfiguration
{
get
{
this._foundryConfiguration ??= this.Configuration.GetSection("AzureAI").Get<AzureAIConfiguration>();
Assert.NotNull(this._foundryConfiguration);
return this._foundryConfiguration;
}
}
public TestOutputAdapter Output { get; }
protected IntegrationTest(ITestOutputHelper output)
@@ -47,7 +66,33 @@ public abstract class IntegrationTest : IDisposable
internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? WorkflowFormulaState.DefaultScopeName}.{variableName}";
protected static IConfigurationRoot InitializeConfig() =>
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation = false)
{
FrozenDictionary<string, string?> agentMap = await AgentFactory.GetAgentsAsync(this.FoundryConfiguration, this.Configuration);
IConfiguration workflowConfig =
new ConfigurationBuilder()
.AddInMemoryCollection(agentMap)
.Build();
AzureAgentProvider agentProvider = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential());
string? conversationId = null;
if (externalConversation)
{
conversationId = await agentProvider.CreateConversationAsync().ConfigureAwait(false);
}
return
new DeclarativeWorkflowOptions(agentProvider)
{
Configuration = workflowConfig,
ConversationId = conversationId,
LoggerFactory = this.Output
};
}
private static IConfigurationRoot InitializeConfig() =>
new ConfigurationBuilder()
.AddJsonFile("appsettings.Development.json", true)
.AddEnvironmentVariables()
@@ -7,6 +7,7 @@ using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Shared.Code;
using Xunit.Sdk;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
@@ -17,7 +18,7 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
public async Task<WorkflowEvents> RunTestcaseAsync<TInput>(Testcase testcase, TInput input) where TInput : notnull
{
WorkflowEvents workflowEvents = await this.RunAsync(input);
WorkflowEvents workflowEvents = await this.RunWorkflowAsync(input);
int requestCount = (workflowEvents.InputEvents.Count + 1) / 2;
int responseCount = 0;
while (requestCount > responseCount)
@@ -36,7 +37,7 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
return workflowEvents;
}
private async Task<WorkflowEvents> RunAsync<TInput>(TInput input) where TInput : notnull
public async Task<WorkflowEvents> RunWorkflowAsync<TInput>(TInput input) where TInput : notnull
{
Console.WriteLine("RUNNING WORKFLOW...");
Checkpointed<StreamingRun> run = await InProcessExecution.StreamAsync(workflow, input, this._checkpointManager, runId);
@@ -98,6 +99,14 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
exitLoop = true;
}
break;
case ExecutorFailedEvent failureEvent:
Console.WriteLine($"Executor failed [{failureEvent.ExecutorId}]: {failureEvent.Data?.Message ?? "Unknown"}");
break;
case WorkflowErrorEvent errorEvent:
throw errorEvent.Data as Exception ?? new XunitException("Unexpected failure...");
case DeclarativeActionInvokedEvent actionInvokeEvent:
Console.WriteLine($"ACTION: {actionInvokeEvent.ActionId} [{actionInvokeEvent.ActionType}]");
break;
@@ -1,17 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.IO;
using System.Linq;
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;
@@ -25,7 +21,8 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o
protected abstract Task RunAndVerifyAsync<TInput>(
Testcase testcase,
string workflowPath,
DeclarativeWorkflowOptions workflowOptions) where TInput : notnull;
DeclarativeWorkflowOptions workflowOptions,
TInput input) where TInput : notnull;
protected Task RunWorkflowAsync(
string workflowPath,
@@ -36,15 +33,14 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o
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),
nameof(ChatMessage) => this.TestWorkflowAsync<ChatMessage>(testcase, workflowPath),
nameof(String) => this.TestWorkflowAsync<string>(testcase, workflowPath),
_ => throw new NotSupportedException($"Input type '{testcase.Setup.Input.Type}' is not supported."),
};
}
@@ -52,38 +48,15 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o
protected async Task TestWorkflowAsync<TInput>(
Testcase testcase,
string workflowPath,
IConfiguration configuration,
bool externalConversation = false) where TInput : notnull
{
this.Output.WriteLine($"INPUT: {testcase.Setup.Input.Value}");
AzureAIConfiguration? foundryConfig = configuration.GetSection("AzureAI").Get<AzureAIConfiguration>();
Assert.NotNull(foundryConfig);
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation).ConfigureAwait(false);
FrozenDictionary<string, string?> agentMap = await AgentFactory.GetAgentsAsync(foundryConfig, configuration);
TInput input = (TInput)GetInput<TInput>(testcase);
IConfiguration workflowConfig =
new ConfigurationBuilder()
.AddInMemoryCollection(agentMap)
.Build();
AzureAgentProvider agentProvider = new(foundryConfig.Endpoint, new AzureCliCredential());
string? conversationId = null;
if (externalConversation)
{
conversationId = await agentProvider.CreateConversationAsync().ConfigureAwait(false);
}
DeclarativeWorkflowOptions workflowOptions =
new(agentProvider)
{
Configuration = workflowConfig,
ConversationId = conversationId,
LoggerFactory = this.Output
};
await this.RunAndVerifyAsync<TInput>(testcase, workflowPath, workflowOptions);
await this.RunAndVerifyAsync(testcase, workflowPath, workflowOptions, input);
}
protected static string? GetConversationId(string? conversationId, IReadOnlyList<ConversationUpdateEvent> conversationEvents)
@@ -101,14 +74,6 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o
return null;
}
protected 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."),
};
protected static Testcase ReadTestcase(string testcaseFileName)
{
using Stream testcaseStream = File.Open(Path.Combine("Testcases", testcaseFileName), FileMode.Open);
@@ -117,6 +82,14 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o
return 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."),
};
internal static string GetRepoFolder()
{
DirectoryInfo? current = new(Directory.GetCurrentDirectory());
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Azure.AI.Agents.Persistent;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
/// <summary>
/// Tests execution of workflow created by <see cref="DeclarativeWorkflowBuilder"/>.
/// </summary>
public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(output)
{
private const string WorkflowFileName = "MediaInput.yaml";
private const string ImageReference = "https://upload.wikimedia.org/wikipedia/commons/5/56/White_shark.jpg";
[Fact(Skip = "Service issue prevents this simple case")]
public async Task ValidateImageUrlAsync()
{
this.Output.WriteLine($"Image: {ImageReference}");
await this.ValidateImageAsync(new UriContent(ImageReference, "image/jpeg"));
}
[Fact]
public async Task ValidateImageDataAsync()
{
byte[] imageData = await DownloadImageAsync();
string encodedData = Convert.ToBase64String(imageData);
string imageUrl = $"data:image/png;base64,{encodedData}";
this.Output.WriteLine($"Image: {imageUrl.Substring(0, 112)}...");
await this.ValidateImageAsync(new DataContent(imageUrl));
}
[Fact]
public async Task ValidateImageUploadAsync()
{
byte[] imageData = await DownloadImageAsync();
PersistentAgentsClient client = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential());
using MemoryStream contentStream = new(imageData);
PersistentAgentFileInfo fileInfo = await client.Files.UploadFileAsync(contentStream, PersistentAgentFilePurpose.Agents, "image.jpg");
try
{
this.Output.WriteLine($"Image: {fileInfo.Id}");
await this.ValidateImageAsync(new HostedFileContent(fileInfo.Id));
}
finally
{
await client.Files.DeleteFileAsync(fileInfo.Id);
}
}
private static async Task<byte[]> DownloadImageAsync()
{
using HttpClient client = new();
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0");
return await client.GetByteArrayAsync(new Uri(ImageReference));
}
private async Task ValidateImageAsync(AIContent imageContent)
{
ChatMessage inputMessage = new(ChatRole.User, [new TextContent("Here is my image:"), imageContent]);
DeclarativeWorkflowOptions options = await this.CreateOptionsAsync();
Workflow workflow = DeclarativeWorkflowBuilder.Build<ChatMessage>(Path.Combine(Environment.CurrentDirectory, "Workflows", WorkflowFileName), options);
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(WorkflowFileName));
WorkflowEvents workflowEvents = await harness.RunWorkflowAsync(inputMessage).ConfigureAwait(false);
Assert.Single(workflowEvents.ConversationEvents);
this.Output.WriteLine("CONVERSATION: " + workflowEvents.ConversationEvents[0].ConversationId);
Assert.Single(workflowEvents.AgentResponseEvents);
this.Output.WriteLine("RESPONSE: " + workflowEvents.AgentResponseEvents[0].Response.Text);
}
}
@@ -0,0 +1,16 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: InvokeAzureAgent
id: invoke_vision
conversationId: =System.ConversationId
agent:
name: =Env.FOUNDRY_AGENT_TEST
input:
additionalInstructions: |-
Describe the image contained in the user request, if any;
otherwise, suggest that the user provide an image.
@@ -13,6 +13,7 @@ using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.AI;
using Moq;
using Xunit.Abstractions;
using Xunit.Sdk;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
@@ -21,7 +22,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// </summary>
public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : WorkflowTest(output)
{
private List<WorkflowEvent> WorkflowEvents { get; set; } = [];
private List<WorkflowEvent> WorkflowEvents { get; } = [];
private Dictionary<Type, int> WorkflowEventCounts { get; set; } = [];
@@ -261,31 +262,42 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, workflowInput);
this.WorkflowEvents = run.WatchStreamAsync().ToEnumerable().ToList();
foreach (WorkflowEvent workflowEvent in this.WorkflowEvents)
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync())
{
if (workflowEvent is ExecutorInvokedEvent invokeEvent)
this.WorkflowEvents.Add(workflowEvent);
switch (workflowEvent)
{
ActionExecutorResult? message = invokeEvent.Data as ActionExecutorResult;
this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]");
}
else if (workflowEvent is DeclarativeActionInvokedEvent actionInvokeEvent)
{
this.Output.WriteLine($"ACTION ENTER: {actionInvokeEvent.ActionId}");
}
else if (workflowEvent is DeclarativeActionCompletedEvent actionCompleteEvent)
{
this.Output.WriteLine($"ACTION EXIT: {actionCompleteEvent.ActionId}");
}
else if (workflowEvent is MessageActivityEvent activityEvent)
{
this.Output.WriteLine($"ACTIVITY: {activityEvent.Message}");
}
else if (workflowEvent is AgentRunResponseEvent messageEvent)
{
this.Output.WriteLine($"MESSAGE: {messageEvent.Response.Messages[0].Text.Trim()}");
case ExecutorInvokedEvent invokeEvent:
ActionExecutorResult? message = invokeEvent.Data as ActionExecutorResult;
this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]");
break;
case DeclarativeActionInvokedEvent actionInvokeEvent:
this.Output.WriteLine($"ACTION ENTER: {actionInvokeEvent.ActionId}");
break;
case DeclarativeActionCompletedEvent actionCompleteEvent:
this.Output.WriteLine($"ACTION EXIT: {actionCompleteEvent.ActionId}");
break;
case MessageActivityEvent activityEvent:
this.Output.WriteLine($"ACTIVITY: {activityEvent.Message}");
break;
case AgentRunResponseEvent messageEvent:
this.Output.WriteLine($"MESSAGE: {messageEvent.Response.Messages[0].Text.Trim()}");
break;
case ExecutorFailedEvent failureEvent:
Console.WriteLine($"Executor failed [{failureEvent.ExecutorId}]: {failureEvent.Data?.Message ?? "Unknown"}");
break;
case WorkflowErrorEvent errorEvent:
throw errorEvent.Data as Exception ?? new XunitException("Unexpected failure...");
}
}
this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count());
}