From f527fbe6ce17310b1b07cbd77a9bbb800f94a435 Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:17:59 -0700 Subject: [PATCH] .NET Workflows - Declarative update for `AzureAgentProvider` usage (#907) * Updated * Update dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Constant / override * Namespace * Protect against race * Simplify collection usage * Rollback name resolution * One more rollback --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../AzureAgentProvider.cs | 12 ++- .../Extensions/IWorkflowContextExtensions.cs | 30 ++++++ .../AddConversationMessageExecutor.cs | 6 +- .../CopyConversationMessagesExecutor.cs | 5 +- .../ObjectModel/InvokeAzureAgentExecutor.cs | 8 +- .../RetrieveConversationMessageExecutor.cs | 4 +- .../RetrieveConversationMessagesExecutor.cs | 4 +- .../PowerFx/WorkflowDiagnostics.cs | 7 +- .../AzureAgentProviderTest.cs | 95 +++++++++++++++++++ .../DeclarativeWorkflowTest.cs | 7 -- .../Framework/WorkflowTest.cs | 8 ++ workflow-samples/Marketing.yaml | 13 ++- 12 files changed, 170 insertions(+), 29 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/AzureAgentProvider.cs index b3a0d044db..09a9db35a8 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/AzureAgentProvider.cs @@ -1,5 +1,4 @@ // Copyright (c) Microsoft. All rights reserved. - using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -43,15 +42,20 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p } /// - public override async Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) + public override Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) { - await this.GetAgentsClient().Messages.CreateMessageAsync( + // TODO: Switch to asynchronous "CreateMessageAsync", when fix properly applied: + // BUG: https://github.com/Azure/azure-sdk-for-net/issues/52571 + // PR: https://github.com/Azure/azure-sdk-for-net/pull/52653 + this.GetAgentsClient().Messages.CreateMessage( conversationId, role: s_roleMap[conversationMessage.Role.Value.ToUpperInvariant()], contentBlocks: GetContent(), attachments: null, metadata: GetMetadata(), - cancellationToken).ConfigureAwait(false); + cancellationToken); + + return Task.CompletedTask; Dictionary? GetMetadata() { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs index b370d6bae0..1ea2f6c8df 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Interpreter; @@ -36,6 +37,35 @@ internal static class IWorkflowContextExtensions public static FormulaValue ReadState(this IWorkflowContext context, string key, string? scopeName = null) => DeclarativeContext(context).State.Get(key, scopeName); + public static async ValueTask QueueConversationUpdateAsync(this IWorkflowContext context, string conversationId) + { + RecordValue conversation = (RecordValue)context.ReadState(SystemScope.Names.Conversation, VariableScopeNames.System); + conversation.UpdateField("Id", FormulaValue.New(conversationId)); + await context.QueueSystemUpdateAsync(SystemScope.Names.Conversation, conversation).ConfigureAwait(false); + await context.QueueSystemUpdateAsync(SystemScope.Names.ConversationId, FormulaValue.New(conversationId)).ConfigureAwait(false); + + await context.AddEventAsync(new ConversationUpdateEvent(conversationId)).ConfigureAwait(false); + } + + // Ensure "System.Conversation.Id" and "System.ConversationId" are properly initialized when referenced. + public static async ValueTask EnsureWorkflowConversationAsync(this IWorkflowContext context, WorkflowAgentProvider agentProvider, StringExpression expression, CancellationToken cancellationToken) + { + if (expression.IsVariableReference && + expression.VariableReference.IsVariableReferenceWithScope(VariableScopeNames.System, out string? variableName)) + { + if (string.Equals(variableName, SystemScope.Names.Conversation, StringComparison.Ordinal) || + string.Equals(variableName, SystemScope.Names.ConversationId, StringComparison.Ordinal)) + { + FormulaValue variableValue = context.ReadState(SystemScope.Names.ConversationId, VariableScopeNames.System); + if (variableValue is BlankValue) + { + string conversationId = await agentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false); + await context.QueueConversationUpdateAsync(conversationId).ConfigureAwait(false); + } + } + } + } + private static DeclarativeWorkflowContext DeclarativeContext(IWorkflowContext context) { if (context is not DeclarativeWorkflowContext declarativeContext) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs index 2cf6ffd6ac..4944d15552 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs @@ -17,8 +17,10 @@ internal sealed class AddConversationMessageExecutor(AddConversationMessage mode { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - StringExpression conversationExpression = Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}"); - string conversationId = this.Evaluator.GetValue(conversationExpression).Value; + Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}"); + await context.EnsureWorkflowConversationAsync(agentProvider, this.Model.ConversationId, cancellationToken).ConfigureAwait(false); + + string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value; ChatMessage newMessage = new(this.Model.Role.Value.ToChatRole(), [.. this.GetContent()]) { AdditionalProperties = this.GetMetadata() }; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs index 1628b00199..997e9bf8fd 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs @@ -17,7 +17,10 @@ internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string conversationId = this.Evaluator.GetValue(Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}")).Value; + Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}"); + await context.EnsureWorkflowConversationAsync(agentProvider, this.Model.ConversationId, cancellationToken).ConfigureAwait(false); + string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value; + DataValue? inputMessages = this.GetInputMessages(); if (inputMessages is not null) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index af250cd5fe..9de6206d62 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -11,7 +11,6 @@ using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; -using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; @@ -79,12 +78,7 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA { conversationId = assignValue; - RecordValue conversation = (RecordValue)context.ReadState(SystemScope.Names.Conversation, VariableScopeNames.System); - conversation.UpdateField("Id", FormulaValue.New(conversationId)); - await context.QueueSystemUpdateAsync(SystemScope.Names.Conversation, conversation).ConfigureAwait(false); - await context.QueueSystemUpdateAsync(SystemScope.Names.ConversationId, FormulaValue.New(conversationId)).ConfigureAwait(false); - - await context.AddEventAsync(new ConversationUpdateEvent(conversationId)).ConfigureAwait(false); + await context.QueueConversationUpdateAsync(conversationId).ConfigureAwait(false); } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessageExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessageExecutor.cs index a723417cba..0fe588faab 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessageExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessageExecutor.cs @@ -16,7 +16,9 @@ internal sealed class RetrieveConversationMessageExecutor(RetrieveConversationMe { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string conversationId = this.Evaluator.GetValue(Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}")).Value; + Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}"); + await context.EnsureWorkflowConversationAsync(agentProvider, this.Model.ConversationId, cancellationToken).ConfigureAwait(false); + string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value; string messageId = this.Evaluator.GetValue(Throw.IfNull(this.Model.MessageId, $"{nameof(this.Model)}.{nameof(this.Model.MessageId)}")).Value; ChatMessage message = await agentProvider.GetMessageAsync(conversationId, messageId, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessagesExecutor.cs index e5a55721ef..f9ccde6a4d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessagesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/RetrieveConversationMessagesExecutor.cs @@ -18,7 +18,9 @@ internal sealed class RetrieveConversationMessagesExecutor(RetrieveConversationM { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string conversationId = this.Evaluator.GetValue(Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}")).Value; + Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}"); + await context.EnsureWorkflowConversationAsync(agentProvider, this.Model.ConversationId, cancellationToken).ConfigureAwait(false); + string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value; ChatMessage[] messages = await agentProvider.GetMessagesAsync( conversationId, diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs index a55e7821d5..7b2486420a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Frozen; using System.Collections.Generic; using System.Linq; using Microsoft.Agents.Workflows.Declarative.Extensions; @@ -14,7 +13,7 @@ using Microsoft.PowerFx.Types; namespace Microsoft.Agents.Workflows.Declarative.PowerFx; -internal sealed record class WorkflowTypeInfo(FrozenSet EnvironmentVariables, IEnumerable UserVariables); +internal sealed record class WorkflowTypeInfo(ISet EnvironmentVariables, IList UserVariables); internal static class WorkflowDiagnostics { @@ -26,8 +25,8 @@ internal static class WorkflowDiagnostics return new WorkflowTypeInfo( - semanticModel.GetAllEnvironmentVariablesReferencedInTheBot().ToFrozenSet(), - semanticModel.GetVariables(workflowElement.SchemaName.Value).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())); + semanticModel.GetAllEnvironmentVariablesReferencedInTheBot(), + [.. semanticModel.GetVariables(workflowElement.SchemaName.Value).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())]); } public static void Initialize(this WorkflowFormulaState scopes, TElement workflowElement, IConfiguration? configuration) where TElement : BotElement, IDialogBase diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs new file mode 100644 index 0000000000..846e554be3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Azure.Identity; +using Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.Configuration; +using Shared.IntegrationTests; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests; + +public sealed class AzureAgentProviderTest(ITestOutputHelper output) : WorkflowTest(output) +{ + private AzureAIConfiguration? _configuration; + + [Fact] + public async Task ConversationTestAsync() + { + // Arrange + AzureAgentProvider provider = new(this.Configuration.Endpoint, new AzureCliCredential()); + // Act + string conversationId = await provider.CreateConversationAsync(); + // Assert + Assert.NotEmpty(conversationId); + + // Arrange & Act + for (int index = 0; index < 3; ++index) + { + await provider.CreateMessageAsync(conversationId, new ChatMessage(ChatRole.User, $"Message #{index * 2}")); + await provider.CreateMessageAsync(conversationId, new ChatMessage(ChatRole.Assistant, $"Message #{(index * 2) + 1}")); + } + + // Act + ChatMessage[] messages = await provider.GetMessagesAsync(conversationId).ToArrayAsync(); + // Assert + Assert.Equal(6, messages.Length); + Assert.NotNull(messages[3].MessageId); + + // Act + ChatMessage message = await provider.GetMessageAsync(conversationId, messages[3].MessageId!); + // Assert + Assert.NotNull(message); + Assert.Equal(messages[3].Text, message.Text); + } + + [Fact] + public async Task GetAgentTestAsync() + { + // Arrange + AzureAgentProvider provider = new(this.Configuration.Endpoint, new AzureCliCredential()); + string agentName = $"TestAgent-{DateTime.UtcNow:yyMMdd-HHmmss-fff}"; + + string agent1Id = await this.CreateAgentAsync(); + string agent2Id = await this.CreateAgentAsync(agentName); + + // Act + AIAgent agent1 = await provider.GetAgentAsync(agent1Id); + // Assert + Assert.Equal(agent1Id, agent1.Id); + + // Act + AIAgent agent2 = await provider.GetAgentAsync(agent2Id); + // Assert + Assert.Equal(agent2Id, agent2.Id); + + // Act & Assert + await Assert.ThrowsAsync(() => provider.GetAgentAsync(agentName)); + } + + 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); + 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.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs index 9fede6797e..c237dbbc38 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -99,12 +98,6 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow return testcase; } - private static IConfigurationRoot InitializeConfig() => - new ConfigurationBuilder() - .AddUserSecrets(Assembly.GetExecutingAssembly()) - .AddEnvironmentVariables() - .Build(); - private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs index afc70f3603..574cbbb3c5 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Reflection; using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.Configuration; using Xunit.Abstractions; namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework; @@ -34,4 +36,10 @@ public abstract class WorkflowTest : IDisposable } internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? VariableScopeNames.Topic}.{variableName}"; + + protected static IConfigurationRoot InitializeConfig() => + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); } diff --git a/workflow-samples/Marketing.yaml b/workflow-samples/Marketing.yaml index a45334ede5..3a9c9c26a1 100644 --- a/workflow-samples/Marketing.yaml +++ b/workflow-samples/Marketing.yaml @@ -1,5 +1,8 @@ # -# This workflow demonstrates a single agent interaction based on user input. +# This workflow demonstrates sequential agent interaction to develop product marketing copy. +# +# Example input: +# An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours. # # Any Foundry Agent may be used to provide the response. # See: ./setup/QuestionAgent.yaml @@ -11,13 +14,19 @@ trigger: id: workflow_demo actions: + - kind: AddConversationMessage + id: add_input_message + conversationId: =System.ConversationId + content: + - type: Text + value: {System.LastMessage.Text} + - kind: InvokeAzureAgent id: invoke_analyst conversationId: =System.ConversationId agent: name: =Env.FOUNDRY_AGENT_ANSWER input: - messages: =[UserMessage(System.LastMessageText)] additionalInstructions: |- You are a marketing analyst. Given a product description, identify: - Key features