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 714ce4747d6..2dcbe8e87a0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs @@ -22,9 +22,13 @@ public static async ValueTask InvokeAgentAsync( { IAsyncEnumerable agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken); - // Enable "autoSend" behavior if this is the workflow conversation. + // Determine whether the target conversation is the workflow conversation + // (used below to decide whether to mirror messages into the workflow conversation + // when an agent runs against a different conversation). The caller's autoSend + // value is honored as-is — when the workflow.yaml specifies autoSend: false the + // raw agent output must not be streamed to the caller, even when the agent is + // running on the workflow conversation. bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId); - autoSend |= isWorkflowConversation; // Process the agent response updates. List updates = []; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 0d64822ee34..69db1d9452f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -71,6 +71,13 @@ public ValueTask ResetAsync() [SendsMessage(typeof(ActionExecutorResult))] public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { + // Establish the Foundry ProductContext on the current async logical context before + // running any code that reads PropertyPath.VariableName / NamespaceAlias. ObjectModel + // resolves those lazily against AsyncLocal; when the workflow is + // hosted (AsAIAgent + AddFoundryResponses) each HTTP request runs on a fresh logical + // context where the build-thread setting does not flow. + WorkflowDiagnostics.SetFoundryProduct(); + if (this.Model.Disabled) { Debug.WriteLine($"DISABLED {this.GetType().Name} [{this.Id}]"); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 322c460ee3a..82dd0b6389f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -192,13 +192,16 @@ private string GetAgentName() => private bool GetAutoSendValue() { - if (this.AgentOutput?.AutoSend is null) + // AzureAgentOutput.AutoSend is never null — it returns a literal-false default + // when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an + // explicit autoSend value from the implicit default, and treat the implicit + // default as autoSend = true (the historical behavior for actions that omit + // autoSend or have no output block at all). + if (this.AgentOutput is { AutoSendIsDefaultValue: false } output) { - return true; + return this.Evaluator.GetValue(output.AutoSend).Value; } - EvaluationResult autoSendResult = this.Evaluator.GetValue(this.AgentOutput.AutoSend); - - return autoSendResult.Value; + return true; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index baa6f9c6b85..6ca429c648a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -103,6 +104,24 @@ public async ValueTask CaptureResponseAsync( FunctionResultContent? matchingResult = functionResults .FirstOrDefault(r => r.CallId == this.Id); + // When the caller approved an approval-required function call but didn't execute it + // locally (the hosted Foundry scenario, where mcp_approval_response is converted to a + // ToolApprovalResponseContent only), invoke the registered AIFunction here so that the + // declarative workflow can capture the result and continue (e.g. for downstream + // SendActivity/PropertyPath consumers like {Local.Result}). + if (matchingResult is null) + { + ToolApprovalResponseContent? approval = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .FirstOrDefault(r => r.RequestId == this.Id); + + if (approval is { Approved: true }) + { + matchingResult = await this.InvokeRegisteredFunctionAsync(cancellationToken).ConfigureAwait(false); + } + } + if (matchingResult is not null) { // Store the result in output variable @@ -241,6 +260,48 @@ private string GetFunctionName() => return conversationIdValue.Length == 0 ? null : conversationIdValue; } + private async ValueTask InvokeRegisteredFunctionAsync(CancellationToken cancellationToken) + { + string functionName = this.GetFunctionName(); + AIFunction? function = agentProvider.Functions?.FirstOrDefault( + f => string.Equals(f.Name, functionName, StringComparison.Ordinal)); + + if (function is null) + { + return new FunctionResultContent(this.Id, result: null) + { + Exception = new InvalidOperationException( + $"Function '{functionName}' is not registered with the agent provider."), + }; + } + + Dictionary? arguments = this.GetArguments(); + AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments); + + object? result; + try + { + result = await function.InvokeAsync(functionArguments, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new FunctionResultContent(this.Id, result: null) { Exception = ex }; + } + + // Match FunctionInvokingChatClient's serialization: pass strings through as-is and + // JSON-serialize anything else so structured results remain consumable by downstream + // PropertyPath consumers such as {Local.RefundResult}. Use AIJsonUtilities so the + // same trim/AOT-friendly serializer chain used elsewhere in the framework is applied. + string serialized = result switch + { + null => string.Empty, + string s => s, + _ => JsonSerializer.Serialize(result, AIJsonUtilities.DefaultOptions.GetTypeInfo(result.GetType())), + }; + + return new FunctionResultContent(this.Id, serialized); + } + private bool GetRequireApproval() { if (this.Model.RequireApproval is null) @@ -253,12 +314,16 @@ private bool GetRequireApproval() private bool GetAutoSendValue() { - if (this.Model.Output?.AutoSend is null) + // InvokeToolOutput.AutoSend is never null — it returns a literal-false default + // when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an + // explicit autoSend value from the implicit default, and treat the implicit + // default as autoSend = true (the historical behavior). + if (this.Model.Output is { AutoSendIsDefaultValue: false } output) { - return true; + return this.Evaluator.GetValue(output.AutoSend).Value; } - return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value; + return true; } private Dictionary? GetArguments() diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs index 7540556f64f..7796a6f4094 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs @@ -311,12 +311,16 @@ private bool GetRequireApproval() private bool GetAutoSendValue() { - if (this.Model.Output?.AutoSend is null) + // InvokeToolOutput.AutoSend is never null — it returns a literal-false default + // when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an + // explicit autoSend value from the implicit default, and treat the implicit + // default as autoSend = true (the historical behavior). + if (this.Model.Output is { AutoSendIsDefaultValue: false } output) { - return true; + return this.Evaluator.GetValue(output.AutoSend).Value; } - return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value; + return true; } private string? GetConnectionName() diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs index 4df1c83c9d0..66af8d52e3b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs @@ -21,12 +21,21 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false); + ChatMessage message = new(ChatRole.Assistant, activityText); + + // Emit an AgentResponseUpdateEvent so chat protocols (e.g. AsAIAgent) receive the + // activity text as streaming chat content. This event is yielded by WorkflowSession + // unconditionally, mirroring how AgentProviderExtensions surfaces autoSend agent + // updates — without it, SendActivity output is dropped whenever the host runs with + // includeWorkflowOutputsInResponse = false (the default). + AgentResponseUpdate update = new(ChatRole.Assistant, activityText) { AuthorName = this.Id }; + await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); + // Route through YieldOutputAsync so the activity participates in the workflow's // output-filter pipeline. The runner currently special-cases AgentResponse to - // produce an AgentResponseEvent identical to the one we'd build by hand, so this - // is behavior-preserving today and forward-compatible if filtering is ever - // applied to agent responses. - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); + // produce an AgentResponseEvent identical to the one we'd build by hand, which + // is the gated summary surfaced only when includeWorkflowOutputsInResponse = true. + AgentResponse response = new([message]); await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs new file mode 100644 index 00000000000..0393401b481 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; +using Microsoft.Agents.ObjectModel; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; +using Moq; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions; + +/// +/// Tests for . +/// +public sealed class AgentProviderExtensionsTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + private const string WorkflowConversationId = "workflow-conv-id"; + private const string AgentName = "test-agent"; + + [Fact] + public Task AutoSendFalseOnWorkflowConversationSuppressesResponseEventsAsync() => + this.RunAsync(autoSend: false, conversationId: WorkflowConversationId, expectResponseEvents: false); + + [Fact] + public Task AutoSendTrueOnWorkflowConversationEmitsResponseEventsAsync() => + this.RunAsync(autoSend: true, conversationId: WorkflowConversationId, expectResponseEvents: true); + + [Fact] + public Task AutoSendFalseOnExternalConversationSuppressesResponseEventsAsync() => + this.RunAsync(autoSend: false, conversationId: "other-conv-id", expectResponseEvents: false); + + [Fact] + public Task AutoSendTrueOnExternalConversationEmitsResponseEventsAndCopiesMessagesAsync() => + this.RunAsync( + autoSend: true, + conversationId: "other-conv-id", + expectResponseEvents: true, + expectCrossConversationCopy: true); + + private async Task RunAsync( + bool autoSend, + string conversationId, + bool expectResponseEvents, + bool expectCrossConversationCopy = false) + { + // Arrange: seed the workflow conversation id so IsWorkflowConversation can recognize it. + this.State.Set( + SystemScope.Names.ConversationId, + FormulaValue.New(WorkflowConversationId), + VariableScopeNames.System); + + MockAgentProvider mockProvider = new(); + AgentResponseUpdate[] updates = + [ + new(ChatRole.Assistant, "hello "), + new(ChatRole.Assistant, "world"), + ]; + mockProvider + .Setup(p => p.InvokeAgentAsync( + AgentName, + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(updates)); + + List<(string ConversationId, ChatMessage Message)> copiedMessages = []; + mockProvider + .Setup(p => p.CreateMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + (convId, msg, _) => + { + copiedMessages.Add((convId, msg)); + return Task.FromResult(msg); + }); + + string actionId = this.CreateActionId().Value; + + // Act + WorkflowEvent[] events = + await this.ExecuteAsync( + actionId, + async (IWorkflowContext context, ActionExecutorResult _, CancellationToken cancellationToken) => + { + await mockProvider.Object.InvokeAgentAsync( + actionId, + context, + AgentName, + conversationId, + autoSend, + cancellationToken: cancellationToken).ConfigureAwait(false); + }); + + // Assert + int updateEventCount = events.OfType().Count(); + int responseEventCount = events.OfType().Count(); + + if (expectResponseEvents) + { + Assert.Equal(updates.Length, updateEventCount); + Assert.Equal(1, responseEventCount); + } + else + { + Assert.Equal(0, updateEventCount); + Assert.Equal(0, responseEventCount); + } + + if (expectCrossConversationCopy) + { + Assert.NotEmpty(copiedMessages); + Assert.All(copiedMessages, c => Assert.Equal(WorkflowConversationId, c.ConversationId)); + } + else + { + Assert.Empty(copiedMessages); + } + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable updates) + { + foreach (AgentResponseUpdate update in updates) + { + yield return update; + } + + await Task.CompletedTask; + } +}