Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
Expand All @@ -22,19 +24,56 @@ public static async ValueTask<AgentResponse> InvokeAgentAsync(
{
IAsyncEnumerable<AgentResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken);

// 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.
// Foundry managed workflows treat responses produced on the workflow conversation
// as workflow output even when autoSend is explicitly false. Preserve that direct-run
// contract here. Workflow.AsAIAgent separately removes matching streamed/completed
// message duplicates at its hosting boundary.
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
autoSend |= isWorkflowConversation;

// Process the agent response updates.
// Assign stable IDs to content-bearing chat updates before emitting and aggregating them.
// Contentless updates may carry only metadata and must not become empty messages.
List<AgentResponseUpdate> updates = [];
string? generatedMessageId = null;
string? generatedMessageResponseId = null;
ChatRole? generatedMessageRole = null;
await foreach (AgentResponseUpdate update in agentUpdates.ConfigureAwait(false))
{
await AssignConversationIdAsync(((ChatResponseUpdate?)update.RawRepresentation)?.ConversationId).ConfigureAwait(false);
await AssignConversationIdAsync((update.RawRepresentation as ChatResponseUpdate)?.ConversationId).ConfigureAwait(false);

if (string.IsNullOrWhiteSpace(update.MessageId))
{
bool hasContent =
update.Contents.Any(
content => content is not TextContent textContent || !string.IsNullOrEmpty(textContent.Text));
if (hasContent)
{
if (generatedMessageId is null
Comment thread
alliscode marked this conversation as resolved.
|| (generatedMessageResponseId is not null
&& update.ResponseId is not null
&& !string.Equals(generatedMessageResponseId, update.ResponseId, StringComparison.Ordinal))
|| (generatedMessageRole is not null
&& update.Role is not null
&& generatedMessageRole != update.Role))
{
generatedMessageId = Guid.NewGuid().ToString("N");
}

generatedMessageResponseId = update.ResponseId ?? generatedMessageResponseId;
generatedMessageRole = update.Role ?? generatedMessageRole;
update.MessageId = generatedMessageId;
if (update.RawRepresentation is ChatResponseUpdate rawUpdate)
{
rawUpdate.MessageId = generatedMessageId;
}
}
}
else
{
generatedMessageId = null;
generatedMessageResponseId = null;
generatedMessageRole = null;
}

updates.Add(update);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
Expand All @@ -21,21 +22,29 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt

await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);

ChatMessage message = new(ChatRole.Assistant, activityText);
string responseId = Guid.NewGuid().ToString("N");
string messageId = Guid.NewGuid().ToString("N");
ChatMessage message = new(ChatRole.Assistant, activityText) { MessageId = messageId };

// 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 };
AgentResponseUpdate update =
new(ChatRole.Assistant, activityText)
{
AuthorName = this.Id,
MessageId = messageId,
ResponseId = responseId,
};
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, which
// is the gated summary surfaced only when includeWorkflowOutputsInResponse = true.
AgentResponse response = new([message]);
AgentResponse response = new([message]) { ResponseId = responseId };
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,46 @@ await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken);

List<AgentResponseUpdate> updates = [];
string? generatedMessageId = null;
string? generatedMessageResponseId = null;
ChatRole? generatedMessageRole = null;
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
// Contentless updates may carry only metadata and must not become empty messages.
if (string.IsNullOrWhiteSpace(update.MessageId))
{
bool hasContent =
update.Contents.Any(
content => content is not TextContent textContent || !string.IsNullOrEmpty(textContent.Text));
if (hasContent)
{
if (generatedMessageId is null
|| (generatedMessageResponseId is not null
&& update.ResponseId is not null
&& !string.Equals(generatedMessageResponseId, update.ResponseId, StringComparison.Ordinal))
|| (generatedMessageRole is not null
&& update.Role is not null
&& generatedMessageRole != update.Role))
{
generatedMessageId = Guid.NewGuid().ToString("N");
}

generatedMessageResponseId = update.ResponseId ?? generatedMessageResponseId;
generatedMessageRole = update.Role ?? generatedMessageRole;
update.MessageId = generatedMessageId;
if (update.RawRepresentation is ChatResponseUpdate rawUpdate)
{
rawUpdate.MessageId = generatedMessageId;
}
}
}
else
{
generatedMessageId = null;
generatedMessageResponseId = null;
generatedMessageRole = null;
}

await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
collector.ProcessAgentResponseUpdate(update);
updates.Add(update);
Expand Down
25 changes: 25 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ IAsyncEnumerable<AgentResponseUpdate> InvokeStageAsync(
#pragma warning restore CA2007

ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo;
HashSet<(string ExecutorId, string MessageId)> streamedMessageIds = [];

// Send a TurnToken to the start executor unless the only activity is an external
// response directed at the start executor itself (which self-emits a TurnToken via
Expand Down Expand Up @@ -485,6 +486,11 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt)
switch (evt)
{
case AgentResponseUpdateEvent agentUpdate:
string? messageId = agentUpdate.Update.MessageId;
if (!string.IsNullOrWhiteSpace(messageId))
{
streamedMessageIds.Add((agentUpdate.ExecutorId, messageId));
}
yield return agentUpdate.Update;
break;

Expand Down Expand Up @@ -559,10 +565,29 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt)
// _includeWorkflowOutputInResponse flag is set. Reason being: The user specifies
// exclusion of an event by enabling filtering and then _not_ marking an Executor
// as an output executor.
bool emittedMessage = false;
bool suppressedStreamedMessage = false;
foreach (ChatMessage message in agentResponse.Response.Messages)
{
string? completedMessageId = message.MessageId;
bool messageWasStreamed =
!string.IsNullOrWhiteSpace(completedMessageId)
&& streamedMessageIds.Contains((agentResponse.ExecutorId, completedMessageId));
if (messageWasStreamed)
{
suppressedStreamedMessage = true;
continue;
}

emittedMessage = true;
yield return this.CreateUpdate(this.LastResponseId, evt, message);
}
if (!emittedMessage && suppressedStreamedMessage)
{
// Preserve the completion event for observability after its correlated
// streamed content has already been forwarded.
yield return CreateObservabilityUpdate(evt);
}
break;

case WorkflowOutputEvent output:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ public sealed class AgentProviderExtensionsTest(ITestOutputHelper output) : Work
private const string AgentName = "test-agent";

[Fact]
public Task AutoSendFalseOnWorkflowConversationSuppressesResponseEventsAsync() =>
this.RunAsync(autoSend: false, conversationId: WorkflowConversationId, expectResponseEvents: false);
public Task AutoSendFalseOnWorkflowConversationStillEmitsResponseEventsAsync() =>
this.RunAsync(autoSend: false, conversationId: WorkflowConversationId, expectResponseEvents: true);

[Fact]
public Task AutoSendTrueOnWorkflowConversationEmitsResponseEventsAsync() =>
Expand Down Expand Up @@ -58,8 +58,13 @@ private async Task RunAsync(
MockAgentProvider mockProvider = new();
AgentResponseUpdate[] updates =
[
new(ChatRole.Assistant, "hello "),
new(ChatRole.Assistant, "world"),
new(new ChatResponseUpdate { MessageId = "" }),
new(new ChatResponseUpdate(ChatRole.Assistant, "hello ") { MessageId = " " }),
new(new ChatResponseUpdate(ChatRole.Assistant, "world") { MessageId = "", Role = null }),
new(ChatRole.Assistant, "!")
{
RawRepresentation = new object(),
},
];
mockProvider
.Setup(p => p.InvokeAgentAsync(
Expand Down Expand Up @@ -109,6 +114,18 @@ await mockProvider.Object.InvokeAgentAsync(
{
Assert.Equal(updates.Length, updateEventCount);
Assert.Equal(1, responseEventCount);

AgentResponseUpdateEvent[] updateEvents = events.OfType<AgentResponseUpdateEvent>().ToArray();
Assert.Equal("", updateEvents[0].Update.MessageId);

string messageId = Assert.IsType<string>(updateEvents[1].Update.MessageId);
Assert.NotEmpty(messageId);
Assert.All(updateEvents.Skip(1), updateEvent => Assert.Equal(messageId, updateEvent.Update.MessageId));

AgentResponseEvent responseEvent = Assert.Single(events.OfType<AgentResponseEvent>());
ChatMessage responseMessage = Assert.Single(responseEvent.Response.Messages);
Assert.Equal(messageId, responseMessage.MessageId);
Assert.Equal("hello world!", responseMessage.Text);
}
else
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,28 @@ public void InvokeAzureAgentThrowsWhenModelInvalid() =>
// Arrange, Act & Assert
Assert.Throws<DeclarativeModelException>(() => new InvokeAzureAgentExecutor(new InvokeAzureAgent(), new CapturingAgentProvider("text"), this.State));

[Theory]
[InlineData(null, true)]
[InlineData(true, true)]
[InlineData(false, false)]
public async Task AutoSendDefaultsToTrueAndHonorsExplicitValueAsync(bool? autoSend, bool expectResponseEvents)
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("response");
InvokeAzureAgent model = this.CreateAutoSendModel(
nameof(AutoSendDefaultsToTrueAndHonorsExplicitValueAsync),
autoSend);

// Act
WorkflowEvent[] events =
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);

// Assert
Assert.Equal(expectResponseEvents ? 1 : 0, events.OfType<AgentResponseUpdateEvent>().Count());
Assert.Equal(expectResponseEvents ? 1 : 0, events.OfType<AgentResponseEvent>().Count());
}

#region Input argument binding

[Fact]
Expand Down Expand Up @@ -306,6 +328,30 @@ private InvokeAzureAgent CreateModel(
return AssignParent<InvokeAzureAgent>(builder);
}

private InvokeAzureAgent CreateAutoSendModel(string displayName, bool? autoSend)
{
AzureAgentOutput.Builder outputBuilder = new();
if (autoSend.HasValue)
{
outputBuilder.AutoSend = new BoolExpression.Builder(BoolExpression.Literal(autoSend.Value));
}

InvokeAzureAgent.Builder builder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Agent =
new AzureAgentUsage.Builder
{
Name = new StringExpression.Builder(StringExpression.Literal("BrainAutoSend")),
},
Output = outputBuilder,
};

return AssignParent<InvokeAzureAgent>(builder);
}

/// <summary>
/// Minimal <see cref="ResponseAgentProvider"/> that returns a single configured text response and
/// captures the input arguments supplied to <see cref="InvokeAgentAsync"/>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ public async Task CaptureActivityAsync()
ChatMessage message = Assert.Single(agentEvent.Response.Messages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Test activity message", message.Text);

AgentResponseUpdateEvent updateEvent = Assert.Single(events.OfType<AgentResponseUpdateEvent>());
Assert.Equal(agentEvent.Response.ResponseId, updateEvent.Update.ResponseId);
Assert.Equal(message.MessageId, updateEvent.Update.MessageId);
}

private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null)
Expand Down
Loading
Loading