Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -22,20 +23,46 @@ 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);

if (string.IsNullOrEmpty(update.MessageId) && update.RawRepresentation is ChatResponseUpdate rawUpdate)
{
if (update.Contents.Count > 0)
{
if (generatedMessageId is null
Comment thread
alliscode marked this conversation as resolved.
|| !string.Equals(generatedMessageResponseId, update.ResponseId, StringComparison.Ordinal)
|| generatedMessageRole != update.Role)
{
generatedMessageId = Guid.NewGuid().ToString("N");
generatedMessageResponseId = update.ResponseId;
generatedMessageRole = update.Role;
}
update.MessageId = generatedMessageId;
rawUpdate.MessageId = generatedMessageId;
}
}
else
{
generatedMessageId = null;
generatedMessageResponseId = null;
generatedMessageRole = null;
}

updates.Add(update);

if (autoSend)
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,35 @@ 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.IsNullOrEmpty(update.MessageId) && update.RawRepresentation is ChatResponseUpdate rawUpdate)
Comment thread
alliscode marked this conversation as resolved.
Outdated
{
if (update.Contents.Count > 0)
{
if (generatedMessageId is null
|| !string.Equals(generatedMessageResponseId, update.ResponseId, StringComparison.Ordinal)
|| generatedMessageRole != update.Role)
{
generatedMessageId = Guid.NewGuid().ToString("N");
generatedMessageResponseId = update.ResponseId;
generatedMessageRole = update.Role;
}
update.MessageId = generatedMessageId;
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
21 changes: 21 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,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 All @@ -473,6 +474,10 @@ IAsyncEnumerable<AgentResponseUpdate> InvokeStageAsync(
switch (evt)
{
case AgentResponseUpdateEvent agentUpdate:
if (agentUpdate.Update.MessageId is { Length: > 0 } messageId)
{
streamedMessageIds.Add((agentUpdate.ExecutorId, messageId));
}
Comment thread
alliscode marked this conversation as resolved.
yield return agentUpdate.Update;
break;

Expand Down Expand Up @@ -544,10 +549,26 @@ IAsyncEnumerable<AgentResponseUpdate> InvokeStageAsync(
// _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;
foreach (ChatMessage message in agentResponse.Response.Messages)
{
bool messageWasStreamed =
message.MessageId is { Length: > 0 } completedMessageId
&& streamedMessageIds.Contains((agentResponse.ExecutorId, completedMessageId));
if (messageWasStreamed)
{
continue;
}

emittedMessage = true;
yield return this.CreateUpdate(this.LastResponseId, evt, message);
}
if (!emittedMessage)
Comment thread
alliscode marked this conversation as resolved.
Outdated
{
// Preserve the completion event for observability after its correlated
// streamed content has already been forwarded.
goto default;
Comment thread
alliscode marked this conversation as resolved.
Outdated
}
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,9 @@ 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")),
];
mockProvider
.Setup(p => p.InvokeAgentAsync(
Expand Down Expand Up @@ -109,6 +110,16 @@ 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>());
Assert.Equal(messageId, Assert.Single(responseEvent.Response.Messages).MessageId);
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,73 @@ public async Task Test_AgentHostExecutor_EmitsResponseIFFConfiguredAsync(bool ex
CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId());
}

[Theory]
[InlineData(null)]
[InlineData("")]
public async Task Test_AgentHostExecutor_AssignsStableMessageIdToContentfulStreamingUpdatesAsync(string? missingMessageId)
{
// Arrange
TestRunContext testContext = new();
AIAgentHostExecutor executor =
new(
new MissingMessageIdAgent(missingMessageId),
new()
{
EmitAgentUpdateEvents = true,
EmitAgentResponseEvents = true,
});
testContext.ConfigureExecutor(executor);

// Act
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));

// Assert
AgentResponseUpdateEvent[] updateEvents = testContext.Events.OfType<AgentResponseUpdateEvent>().ToArray();
updateEvents.Should().HaveCount(3);
updateEvents[0].Update.MessageId.Should().Be(missingMessageId);

string? messageId = updateEvents[1].Update.MessageId;
messageId.Should().NotBeNullOrEmpty();
updateEvents.Skip(1).Should().OnlyContain(updateEvent => updateEvent.Update.MessageId == messageId);

AgentResponseEvent responseEvent = testContext.Events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
responseEvent.Response.Messages.Should().ContainSingle().Which.MessageId.Should().Be(messageId);
}

private static ChatMessage UserMessage => new(ChatRole.User, "Hello from User!") { AuthorName = "User" };
private static ChatMessage AssistantMessage => new(ChatRole.Assistant, "Hello from Assistant!") { AuthorName = "User" };
private static ChatMessage TestAgentMessage => new(ChatRole.Assistant, $"Hello from {TestAgentName}!") { AuthorName = TestAgentName };

private sealed class MissingMessageIdAgent(string? messageId) : TestReplayAgent
{
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
yield return new AgentResponseUpdate(
new ChatResponseUpdate
{
MessageId = messageId,
ResponseId = "response-id",
});
yield return new AgentResponseUpdate(
new ChatResponseUpdate(ChatRole.Assistant, "hello ")
{
MessageId = messageId,
ResponseId = "response-id",
});
yield return new AgentResponseUpdate(
new ChatResponseUpdate(ChatRole.Assistant, "world")
{
MessageId = messageId,
ResponseId = "response-id",
});
await Task.CompletedTask;
}
}

[Theory]
[InlineData(true, true, false, false)]
[InlineData(true, true, false, true)]
Expand Down
Loading
Loading