Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@ public static async ValueTask<AgentResponse> InvokeAgentAsync(
{
IAsyncEnumerable<AgentResponseUpdate> 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);
Comment thread
alliscode marked this conversation as resolved.
Comment thread
alliscode marked this conversation as resolved.
autoSend |= isWorkflowConversation;

// Process the agent response updates.
List<AgentResponseUpdate> updates = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProductContext>; 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}]");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> autoSendResult = this.Evaluator.GetValue(this.AgentOutput.AutoSend);

return autoSendResult.Value;
return true;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
Expand Down Expand Up @@ -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<ToolApprovalResponseContent>()
.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
Expand Down Expand Up @@ -241,6 +260,48 @@ private string GetFunctionName() =>
return conversationIdValue.Length == 0 ? null : conversationIdValue;
}

private async ValueTask<FunctionResultContent?> 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."),
};
}
Comment thread
alliscode marked this conversation as resolved.

Dictionary<string, object?>? 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)
Expand All @@ -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<string, object?>? GetArguments()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for <see cref="AgentProviderExtensions.InvokeAgentAsync"/>.
/// </summary>
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);
Comment thread
alliscode marked this conversation as resolved.

[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<string?>(),
It.IsAny<string?>(),
It.IsAny<IEnumerable<ChatMessage>?>(),
It.IsAny<IDictionary<string, object?>?>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(updates));

List<(string ConversationId, ChatMessage Message)> copiedMessages = [];
mockProvider
.Setup(p => p.CreateMessageAsync(
It.IsAny<string>(),
It.IsAny<ChatMessage>(),
It.IsAny<CancellationToken>()))
.Returns<string, ChatMessage, CancellationToken>(
(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<AgentResponseUpdateEvent>().Count();
int responseEventCount = events.OfType<AgentResponseEvent>().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<AgentResponseUpdate> ToAsyncEnumerableAsync(IEnumerable<AgentResponseUpdate> updates)
{
foreach (AgentResponseUpdate update in updates)
{
yield return update;
}

await Task.CompletedTask;
}
}
Loading