mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
@@ -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
|
||||
};
|
||||
|
||||
|
||||
+16
@@ -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);
|
||||
|
||||
+7
-2
@@ -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
|
||||
{
|
||||
|
||||
+3
-4
@@ -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>
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+4
-22
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-4
@@ -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
|
||||
|
||||
+8
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+46
-1
@@ -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()
|
||||
|
||||
+11
-2
@@ -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;
|
||||
|
||||
+15
-42
@@ -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());
|
||||
|
||||
+79
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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.
|
||||
+34
-22
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user