Skip to content
Open
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
20 changes: 20 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.CopilotStudio/ActivityProcessor.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.Core.Models;
using Microsoft.Extensions.AI;
Expand Down Expand Up @@ -38,8 +39,27 @@ public static async IAsyncEnumerable<ChatMessage> ProcessActivityAsync(IAsyncEnu
private static ChatMessage CreateChatMessageFromActivity(IActivity activity, IEnumerable<AIContent> messageContent) =>
new(ChatRole.Assistant, [.. messageContent])
{
AdditionalProperties = MapAdditionalProperties(activity),
AuthorName = activity.From?.Name,
CreatedAt = activity.Timestamp,
MessageId = activity.Id,
RawRepresentation = activity
};

private static AdditionalPropertiesDictionary? MapAdditionalProperties(IActivity activity)
{
IDictionary<string, JsonElement>? properties = activity.Properties;
if (properties is null || properties.Count == 0)
{
return null;
}

var additionalProperties = new AdditionalPropertiesDictionary();
foreach (KeyValuePair<string, JsonElement> property in properties)
{
additionalProperties[property.Key] = property.Value;
}

return additionalProperties;
}
}
125 changes: 104 additions & 21 deletions dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.ExceptionServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -98,14 +99,7 @@ protected override async Task<AgentResponse> RunCoreAsync(
responseMessagesList.Add(message);
}

// TODO: Review list of ChatResponse properties to ensure we set all availble values.
// Setting ResponseId and MessageId end up being particularly important for streaming consumers
// so that they can tell things like response boundaries.
return new AgentResponse(responseMessagesList)
{
AgentId = this.Id,
ResponseId = responseMessagesList.LastOrDefault()?.MessageId,
};
return CreateAgentResponse(responseMessagesList, this.Id);
}

/// <inheritdoc/>
Expand All @@ -132,24 +126,113 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingA
string question = string.Join("\n", messages.Select(m => m.Text));
var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, typedSession.ConversationId, cancellationToken), streaming: true, this._logger);

// Enumerate the response messages
await foreach (ChatMessage message in responseMessages.ConfigureAwait(false))
await foreach (AgentResponseUpdate update in CreateAgentResponseUpdatesAsync(responseMessages, this.Id, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}

/// <summary>
/// Builds an <see cref="AgentResponse"/> from the messages returned by the Copilot Studio agent,
/// populating the response-level metadata (such as <see cref="AgentResponse.CreatedAt"/>,
/// <see cref="AgentResponse.FinishReason"/> and <see cref="AgentResponse.RawRepresentation"/>) from the
/// final message so that consumers see the same surface as other <see cref="AIAgent"/> implementations.
/// </summary>
internal static AgentResponse CreateAgentResponse(IList<ChatMessage> messages, string? agentId)
{
ChatMessage? lastMessage = messages.Count > 0 ? messages[messages.Count - 1] : null;

return new AgentResponse(messages)
{
AgentId = agentId,
ResponseId = lastMessage?.MessageId,
CreatedAt = lastMessage?.CreatedAt,
FinishReason = ChatFinishReason.Stop,
RawRepresentation = lastMessage?.RawRepresentation,
AdditionalProperties = lastMessage?.AdditionalProperties,
};
}

/// <summary>
/// Projects the streamed <see cref="ChatMessage"/> sequence onto <see cref="AgentResponseUpdate"/> instances,
/// carrying per-update metadata and setting <see cref="AgentResponseUpdate.FinishReason"/> only on the terminal
/// update so streaming consumers can detect the response boundary.
/// </summary>
internal static async IAsyncEnumerable<AgentResponseUpdate> CreateAgentResponseUpdatesAsync(
IAsyncEnumerable<ChatMessage> messages,
string? agentId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Buffer a single message so we know which update is the terminal one (it carries the finish reason).
// Manual enumeration lets us still emit any already-received content if the source faults mid-stream,
// preserving the original streaming behavior, before re-throwing the original exception.
ChatMessage? pending = null;
ExceptionDispatchInfo? failure = null;

IAsyncEnumerator<ChatMessage> enumerator = messages.GetAsyncEnumerator(cancellationToken);
try
{
// TODO: Review list of ChatResponse properties to ensure we set all availble values.
// Setting ResponseId and MessageId end up being particularly important for streaming consumers
// so that they can tell things like response boundaries.
yield return new AgentResponseUpdate(message.Role, message.Contents)
while (true)
{
bool moved;
try
{
moved = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
failure = ExceptionDispatchInfo.Capture(ex);
break;
}

if (!moved)
{
break;
}

if (pending is not null)
{
yield return CreateAgentResponseUpdate(pending, agentId, finishReason: null);
}

pending = enumerator.Current;
}
Comment on lines +166 to +199

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one-message buffer is deliberate: a stream doesn't announce its final element ahead of time, so to set FinishReason=Stop on the terminal update we need one element of lookahead to know which update is last. The alternatives seemed worse — either emit no finish reason on any streamed update, or append a synthetic empty terminal update. The cost is a single-message delay, which is negligible for token streaming. Happy to switch to one of those approaches if you'd prefer.

}
finally
{
try
{
await enumerator.DisposeAsync().ConfigureAwait(false);
}
catch when (failure is not null)
{
AgentId = this.Id,
AdditionalProperties = message.AdditionalProperties,
AuthorName = message.AuthorName,
RawRepresentation = message.RawRepresentation,
ResponseId = message.MessageId,
MessageId = message.MessageId,
};
// A fault was already captured from the stream; don't let a disposal
// exception override the original streaming exception.
}
}

if (pending is not null)
{
// The last received message is the terminal update only when the stream completed successfully.
yield return CreateAgentResponseUpdate(pending, agentId, finishReason: failure is null ? ChatFinishReason.Stop : null);
}

failure?.Throw();
}

private static AgentResponseUpdate CreateAgentResponseUpdate(ChatMessage message, string? agentId, ChatFinishReason? finishReason) =>
new(message.Role, message.Contents)
{
AgentId = agentId,
AdditionalProperties = message.AdditionalProperties,
AuthorName = message.AuthorName,
CreatedAt = message.CreatedAt,
FinishReason = finishReason,
RawRepresentation = message.RawRepresentation,
ResponseId = message.MessageId,
MessageId = message.MessageId,
};

private async Task<string> StartNewConversationAsync(CancellationToken cancellationToken)
{
string? conversationId = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
<PackageReference Include="Microsoft.Agents.CopilotStudio.Client" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
</ItemGroup>

<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Copilot Studio</Title>
Expand Down
Loading
Loading