diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 7060d252ae..69dc92a9cb 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -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 }} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs index ca8032f808..e7072c69c1 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs @@ -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; } } } diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs index f9754ab22e..8d9b4a6504 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs @@ -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)"}]"); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs index 141233c2cb..562a77a8d9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs @@ -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 }; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs index 20a825454a..5b74c8f7a7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs @@ -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 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 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); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs index c8df8c277a..f4f3bb65af 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -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(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(this RecordDataValue record, string name) where TValue : DataValue { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj index 6cac284f46..9e23c5f727 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj @@ -1,4 +1,4 @@ - + $(ProjectsTargetFrameworks) @@ -21,16 +21,15 @@ - - - + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgent.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgent.yaml index a85f303577..eddcc7b0aa 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgent.yaml +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgent.yaml @@ -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} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs index 45b7735eb9..87b9160196 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs @@ -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 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(); - Assert.NotNull(this._configuration); - } - - return this._configuration; - } - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeCodeGenTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeCodeGenTest.cs index 0dd9f08940..2ad0782602 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeCodeGenTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeCodeGenTest.cs @@ -12,7 +12,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; /// /// Tests execution of workflow created by . /// -[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(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions) + protected override async Task RunAndVerifyAsync(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(testcase)); + input); - WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, (TInput)GetInput(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 diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs index 2937444c19..00f9c59ada 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs @@ -12,7 +12,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; /// /// Tests execution of workflow created by . /// -[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(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions) + protected override async Task RunAndVerifyAsync(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input) { Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); - WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, (TInput)GetInput(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); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs index 60aa4f38df..deccff658d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs @@ -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; /// 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(); + 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 CreateOptionsAsync(bool externalConversation = false) + { + FrozenDictionary 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() diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs index 797e254800..8ad1def744 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs @@ -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 RunTestcaseAsync(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 RunAsync(TInput input) where TInput : notnull + public async Task RunWorkflowAsync(TInput input) where TInput : notnull { Console.WriteLine("RUNNING WORKFLOW..."); Checkpointed 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; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs index a3ad1a5983..ccef59c88e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs @@ -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( 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(testcase, workflowPath, configuration), - nameof(String) => this.TestWorkflowAsync(testcase, workflowPath, configuration), + nameof(ChatMessage) => this.TestWorkflowAsync(testcase, workflowPath), + nameof(String) => this.TestWorkflowAsync(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( 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(); - Assert.NotNull(foundryConfig); + DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation).ConfigureAwait(false); - FrozenDictionary agentMap = await AgentFactory.GetAgentsAsync(foundryConfig, configuration); + TInput input = (TInput)GetInput(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(testcase, workflowPath, workflowOptions); + await this.RunAndVerifyAsync(testcase, workflowPath, workflowOptions, input); } protected static string? GetConversationId(string? conversationId, IReadOnlyList conversationEvents) @@ -101,14 +74,6 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o return null; } - protected static object GetInput(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(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()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs new file mode 100644 index 0000000000..280df88b4b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs @@ -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; + +/// +/// Tests execution of workflow created by . +/// +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 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(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); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/MediaInput.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/MediaInput.yaml new file mode 100644 index 0000000000..c2a428f6d4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/MediaInput.yaml @@ -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. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index ba77761906..63d109161e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -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; /// public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : WorkflowTest(output) { - private List WorkflowEvents { get; set; } = []; + private List WorkflowEvents { get; } = []; private Dictionary 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()); }