Run Response ADR & Updates (#104)

* Add ADR for different run response options

* Add another option to the list.

* Update agno non-streaming with further clarification

* Add another option

* Adding optional includeUpdates option

* Adding Pros/Cons for each option

* Make pros/cons a list

* Add some thoughts on structured outputs and custom AIContent types

* Update design doc to clarify primary and secondary better and split out custom response types with it's own options

* Add structured outputs competitive comparison and suggestion

* Address PR comments.

* Remove AgentRunFinishReason until we can find a good use case for it.

* Add finish reason to list of excluded properties.

* Add custom agent run response types.
Usage to follow.

* Update Agent run response types

* Add additional code coverage

* Remove onIntermediateMessage since it is unecessary with the new response approach.

* Add AgentId to response.

* Rename ParseAsStructuredOutput to Deserialize

* Update decision doc.

* Fix formatting.

* Update CopilotStudio to return new response types

* Address PR comment

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
This commit is contained in:
westey
2025-07-11 11:39:18 +00:00
committed by GitHub
co-authored by Roger Barreto
parent 33d09d263b
commit 715769e649
36 changed files with 2143 additions and 260 deletions
@@ -64,7 +64,7 @@ public abstract class AgentActor : OrchestrationActor
/// <remarks>
/// Override this method to customize the invocation of the agent.
/// </remarks>
protected virtual Task InvokeAsync(
protected virtual Task<AgentRunResponse> InvokeAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentRunOptions options,
CancellationToken cancellationToken = default) =>
@@ -84,7 +84,7 @@ public abstract class AgentActor : OrchestrationActor
/// <remarks>
/// Override this method to customize the invocation of the agent.
/// </remarks>
protected virtual IAsyncEnumerable<ChatResponseUpdate> InvokeStreamingAsync(IReadOnlyCollection<ChatMessage> messages, AgentRunOptions options, CancellationToken cancellationToken) =>
protected virtual IAsyncEnumerable<AgentRunResponseUpdate> InvokeStreamingAsync(IReadOnlyCollection<ChatMessage> messages, AgentRunOptions options, CancellationToken cancellationToken) =>
this.Agent.RunStreamingAsync(
messages,
this.Thread,
@@ -111,54 +111,46 @@ public abstract class AgentActor : OrchestrationActor
{
this.Context.Cancellation.ThrowIfCancellationRequested();
List<ChatMessage>? responseMessages = [];
ChatResponse response = new(responseMessages);
AgentRunOptions options =
new()
{
OnIntermediateMessages = HandleMessage,
};
AgentRunOptions options = new();
if (this.Context.StreamingResponseCallback == null)
{
// No need to utilize streaming if no callback is provided
await this.InvokeAsync([.. input], options, cancellationToken).ConfigureAwait(false);
}
else
{
IAsyncEnumerable<ChatResponseUpdate> streamedResponses = this.InvokeStreamingAsync([.. input], options, cancellationToken);
ChatResponseUpdate? lastStreamedResponse = null;
await foreach (ChatResponseUpdate streamedResponse in streamedResponses.ConfigureAwait(false))
{
this.Context.Cancellation.ThrowIfCancellationRequested();
await HandleStreamedMessage(lastStreamedResponse, isFinal: false).ConfigureAwait(false);
lastStreamedResponse = streamedResponse;
}
await HandleStreamedMessage(lastStreamedResponse, isFinal: true).ConfigureAwait(false);
}
return response.Messages.Last();
async Task HandleMessage(IReadOnlyCollection<ChatMessage> messages)
{
responseMessages?.AddRange(messages);
AgentRunResponse response = await this.InvokeAsync([.. input], options, cancellationToken).ConfigureAwait(false);
if (this.Context.ResponseCallback is not null)
{
await this.Context.ResponseCallback.Invoke(messages).ConfigureAwait(false);
await this.Context.ResponseCallback.Invoke(response.Messages).ConfigureAwait(false);
}
return response.Messages.Last();
}
async ValueTask HandleStreamedMessage(ChatResponseUpdate? streamedResponse, bool isFinal)
IAsyncEnumerable<AgentRunResponseUpdate> streamedResponses = this.InvokeStreamingAsync([.. input], options, cancellationToken);
AgentRunResponseUpdate? lastStreamedResponse = null;
List<AgentRunResponseUpdate> updates = [];
await foreach (AgentRunResponseUpdate streamedResponse in streamedResponses.ConfigureAwait(false))
{
this.Context.Cancellation.ThrowIfCancellationRequested();
await HandleStreamedMessage(lastStreamedResponse, isFinal: false).ConfigureAwait(false);
lastStreamedResponse = streamedResponse;
}
return updates.ToAgentRunResponse().Messages.Last();
async ValueTask HandleStreamedMessage(AgentRunResponseUpdate? streamedResponse, bool isFinal)
{
if (this.Context.StreamingResponseCallback != null && streamedResponse != null)
{
await this.Context.StreamingResponseCallback.Invoke(streamedResponse, isFinal).ConfigureAwait(false);
}
if (streamedResponse != null)
{
updates.Add(streamedResponse);
}
}
}
}
@@ -27,7 +27,7 @@ public delegate ValueTask OrchestrationResponseCallback(IEnumerable<ChatMessage>
/// </summary>
/// <param name="response">The agent response</param>
/// <param name="isFinal">Indicates if streamed content is final chunk of the message.</param>
public delegate ValueTask OrchestrationStreamingCallback(ChatResponseUpdate response, bool isFinal);
public delegate ValueTask OrchestrationStreamingCallback(AgentRunResponseUpdate response, bool isFinal);
/// <summary>
/// Called when human interaction is requested.
@@ -60,7 +60,7 @@ internal sealed class HandoffActor : AgentActor
}
/// <inheritdoc/>
protected override Task InvokeAsync(
protected override Task<AgentRunResponse> InvokeAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentRunOptions options,
CancellationToken cancellationToken = default) =>
@@ -72,7 +72,7 @@ internal sealed class HandoffActor : AgentActor
cancellationToken);
/// <inheritdoc/>
protected override IAsyncEnumerable<ChatResponseUpdate> InvokeStreamingAsync(IReadOnlyCollection<ChatMessage> messages, AgentRunOptions options, CancellationToken cancellationToken) =>
protected override IAsyncEnumerable<AgentRunResponseUpdate> InvokeStreamingAsync(IReadOnlyCollection<ChatMessage> messages, AgentRunOptions options, CancellationToken cancellationToken) =>
this._chatAgent.RunStreamingAsync(
messages,
this.Thread,
@@ -0,0 +1,118 @@
// Copyright (c) Microsoft. All rights reserved.
#if NET
using System;
#endif
using System.Collections.Generic;
using System.Linq;
#if NET
using System.Runtime.CompilerServices;
#else
using System.Text;
#endif
namespace Microsoft.Extensions.AI.Agents;
// TODO: Consolidate with same internal class in Microsoft.Extensions.AI.Abstractions when both are available in the same repository.
/// <summary>Internal extensions for working with <see cref="AIContent"/>.</summary>
internal static class AIContentExtensions
{
/// <summary>Concatenates the text of all <see cref="TextContent"/> instances in the list.</summary>
public static string ConcatText(this IEnumerable<AIContent> contents)
{
if (contents is IList<AIContent> list)
{
int count = list.Count;
switch (count)
{
case 0:
return string.Empty;
case 1:
return (list[0] as TextContent)?.Text ?? string.Empty;
default:
#if NET
DefaultInterpolatedStringHandler builder = new(count, 0, null, stackalloc char[512]);
for (int i = 0; i < count; i++)
{
if (list[i] is TextContent text)
{
builder.AppendLiteral(text.Text);
}
}
return builder.ToStringAndClear();
#else
StringBuilder builder = new();
for (int i = 0; i < count; i++)
{
if (list[i] is TextContent text)
{
builder.Append(text.Text);
}
}
return builder.ToString();
#endif
}
}
return string.Concat(contents.OfType<TextContent>());
}
/// <summary>Concatenates the <see cref="ChatMessage.Text"/> of all <see cref="ChatMessage"/> instances in the list.</summary>
/// <remarks>A newline separator is added between each non-empty piece of text.</remarks>
public static string ConcatText(this IList<ChatMessage> messages)
{
int count = messages.Count;
switch (count)
{
case 0:
return string.Empty;
case 1:
return messages[0].Text;
default:
#if NET
DefaultInterpolatedStringHandler builder = new(count, 0, null, stackalloc char[512]);
bool needsSeparator = false;
for (int i = 0; i < count; i++)
{
string text = messages[i].Text;
if (text.Length > 0)
{
if (needsSeparator)
{
builder.AppendLiteral(Environment.NewLine);
}
builder.AppendLiteral(text);
needsSeparator = true;
}
}
return builder.ToStringAndClear();
#else
StringBuilder builder = new();
for (int i = 0; i < count; i++)
{
string text = messages[i].Text;
if (text.Length > 0)
{
if (builder.Length > 0)
{
builder.AppendLine();
}
builder.Append(text);
}
}
return builder.ToString();
#endif
}
}
}
@@ -53,8 +53,8 @@ public abstract class Agent
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public Task<ChatResponse> RunAsync(
/// <returns>A <see cref="AgentRunResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public Task<AgentRunResponse> RunAsync(
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
@@ -69,11 +69,11 @@ public abstract class Agent
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
/// <returns>A <see cref="AgentRunResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
/// <remarks>
/// The provided message string will be treated as a user message.
/// </remarks>
public Task<ChatResponse> RunAsync(
public Task<AgentRunResponse> RunAsync(
string message,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -91,8 +91,8 @@ public abstract class Agent
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public Task<ChatResponse> RunAsync(
/// <returns>A <see cref="AgentRunResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public Task<AgentRunResponse> RunAsync(
ChatMessage message,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -110,8 +110,8 @@ public abstract class Agent
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public abstract Task<ChatResponse> RunAsync(
/// <returns>A <see cref="AgentRunResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public abstract Task<AgentRunResponse> RunAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -123,8 +123,8 @@ public abstract class Agent
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async list of response items that each contain a <see cref="ChatResponseUpdate"/>.</returns>
public IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
/// <returns>An async list of response items that each contain a <see cref="AgentRunResponseUpdate"/>.</returns>
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
@@ -139,11 +139,11 @@ public abstract class Agent
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async list of response items that each contain a <see cref="ChatResponseUpdate"/>.</returns>
/// <returns>An async list of response items that each contain a <see cref="AgentRunResponseUpdate"/>.</returns>
/// <remarks>
/// The provided message string will be treated as a user message.
/// </remarks>
public IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
string message,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -161,8 +161,8 @@ public abstract class Agent
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async list of response items that each contain a <see cref="ChatResponseUpdate"/>.</returns>
public IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
/// <returns>An async list of response items that each contain a <see cref="AgentRunResponseUpdate"/>.</returns>
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
ChatMessage message,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -180,8 +180,8 @@ public abstract class Agent
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async list of response items that each contain a <see cref="ChatResponseUpdate"/>.</returns>
public abstract IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
/// <returns>An async list of response items that each contain a <see cref="AgentRunResponseUpdate"/>.</returns>
public abstract IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -1,8 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents;
@@ -26,17 +23,5 @@ public class AgentRunOptions
public AgentRunOptions(AgentRunOptions options)
{
Throw.IfNull(options);
this.OnIntermediateMessages = options.OnIntermediateMessages;
}
/// <summary>
/// Gets or sets a function to be called when a complete new message is generated by the agent.
/// </summary>
/// <remarks>
/// <para>
/// This callback is particularly useful in cases where the caller wants to receive complete messages
/// when invoking the agent with streaming.
/// </para>
/// </remarks>
public Func<IReadOnlyCollection<ChatMessage>, Task>? OnIntermediateMessages { get; set; } = null;
}
@@ -0,0 +1,244 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>Represents the response to an Agent run request.</summary>
/// <remarks>
/// <see cref="AgentRunResponse"/> provides one or more response messages and metadata about the response.
/// A typical response will contain a single message, however a response may contain multiple messages
/// in a variety of scenarios. For example, if the agent internally invokes functions or tools, performs
/// RAG retrievals or has other complex logic, a single run by the agent may produce many messages showing
/// the intermediate progress that the agent made towards producing the agent result.
/// </remarks>
public class AgentRunResponse
{
private static readonly JsonReaderOptions s_allowMultipleValuesJsonReaderOptions = new()
{
#if NET9_0_OR_GREATER
AllowMultipleValues = true
#endif
};
/// <summary>The response messages.</summary>
private IList<ChatMessage>? _messages;
/// <summary>Initializes a new instance of the <see cref="AgentRunResponse"/> class.</summary>
public AgentRunResponse()
{
}
/// <summary>Initializes a new instance of the <see cref="AgentRunResponse"/> class.</summary>
/// <param name="message">The response message.</param>
/// <exception cref="ArgumentNullException"><paramref name="message"/> is <see langword="null"/>.</exception>
public AgentRunResponse(ChatMessage message)
{
_ = Throw.IfNull(message);
this.Messages.Add(message);
}
/// <summary>Initializes a new instance of the <see cref="AgentRunResponse"/> class.</summary>
/// <param name="messages">The response messages.</param>
public AgentRunResponse(IList<ChatMessage>? messages)
{
this._messages = messages;
}
/// <summary>Gets or sets the agent response messages.</summary>
[AllowNull]
public IList<ChatMessage> Messages
{
get => this._messages ??= new List<ChatMessage>(1);
set => this._messages = value;
}
/// <summary>Gets the text of the response.</summary>
/// <remarks>
/// This property concatenates the <see cref="ChatMessage.Text"/> of all <see cref="ChatMessage"/>
/// instances in <see cref="Messages"/>.
/// </remarks>
[JsonIgnore]
public string Text => this._messages?.ConcatText() ?? string.Empty;
/// <summary>Gets or sets the ID of the agent that produced the response.</summary>
public string? AgentId { get; set; }
/// <summary>Gets or sets the ID of the agent response.</summary>
public string? ResponseId { get; set; }
/// <summary>Gets or sets a timestamp for the run response.</summary>
public DateTimeOffset? CreatedAt { get; set; }
/// <summary>Gets or sets usage details for the run response.</summary>
/// <remarks>
/// Where the agent run response is produced via many model invocations, this
/// usage is an aggregation of the usage for all these model invocations.
/// </remarks>
public UsageDetails? Usage { get; set; }
/// <summary>Gets or sets the raw representation of the run response from an underlying implementation.</summary>
/// <remarks>
/// If a <see cref="AgentRunResponse"/> is created to represent some underlying object from another object
/// model, this property can be used to store that original object. This can be useful for debugging or
/// for enabling a consumer to access the underlying object model if needed.
/// </remarks>
[JsonIgnore]
public object? RawRepresentation { get; set; }
/// <summary>Gets or sets any additional properties associated with the run response.</summary>
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
/// <inheritdoc />
public override string ToString() => this.Text;
/// <summary>Creates an array of <see cref="AgentRunResponseUpdate" /> instances that represent this <see cref="AgentRunResponse" />.</summary>
/// <returns>An array of <see cref="AgentRunResponseUpdate" /> instances that may be used to represent this <see cref="AgentRunResponse" />.</returns>
public AgentRunResponseUpdate[] ToAgentRunResponseUpdates()
{
AgentRunResponseUpdate? extra = null;
if (this.AdditionalProperties is not null || this.Usage is not null)
{
extra = new AgentRunResponseUpdate
{
AdditionalProperties = this.AdditionalProperties
};
if (this.Usage is { } usage)
{
extra.Contents.Add(new UsageContent(usage));
}
}
int messageCount = this._messages?.Count ?? 0;
var updates = new AgentRunResponseUpdate[messageCount + (extra is not null ? 1 : 0)];
int i;
for (i = 0; i < messageCount; i++)
{
ChatMessage message = this._messages![i];
updates[i] = new AgentRunResponseUpdate
{
AdditionalProperties = message.AdditionalProperties,
AuthorName = message.AuthorName,
Contents = message.Contents,
RawRepresentation = message.RawRepresentation,
Role = message.Role,
AgentId = this.AgentId,
ResponseId = this.ResponseId,
MessageId = message.MessageId,
CreatedAt = this.CreatedAt,
};
}
if (extra is not null)
{
updates[i] = extra;
}
return updates;
}
// TODO: Add overloads without serializer options.
/// <summary>
/// Deserializes the response text into the given type using the specified serializer options.
/// </summary>
/// <typeparam name="T">The output type to deserialize into.</typeparam>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <returns>The result as the requested type.</returns>
/// <exception cref="InvalidOperationException">The result is not parsable into the requested type.</exception>
public T Deserialize<T>(JsonSerializerOptions serializerOptions)
{
var structuredOutput = this.GetResultCore<T>(serializerOptions, out var failureReason);
return failureReason switch
{
FailureReason.ResultDidNotContainJson => throw new InvalidOperationException("The response did not contain JSON to be deserialized."),
FailureReason.DeserializationProducedNull => throw new InvalidOperationException("The deserialized response is null."),
_ => structuredOutput!,
};
}
/// <summary>
/// Tries to deserialize response text into the given type using the specified serializer options.
/// </summary>
/// <typeparam name="T">The output type to deserialize into.</typeparam>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="structuredOutput">The parsed structured output.</param>
/// <returns><see langword="true" /> if parsing was successful; otherwise, <see langword="false" />.</returns>
public bool TryDeserialize<T>(JsonSerializerOptions serializerOptions, [NotNullWhen(true)] out T? structuredOutput)
{
try
{
structuredOutput = this.GetResultCore<T>(serializerOptions, out var failureReason);
return failureReason is null;
}
#pragma warning disable CA1031 // Do not catch general exception types
catch
{
structuredOutput = default;
return false;
}
#pragma warning restore CA1031 // Do not catch general exception types
}
private static T? DeserializeFirstTopLevelObject<T>(string json, JsonTypeInfo<T> typeInfo)
{
// We need to deserialize only the first top-level object as a workaround for a common LLM backend
// issue. GPT 3.5 Turbo commonly returns multiple top-level objects after doing a function call.
// See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348
var utf8ByteLength = Encoding.UTF8.GetByteCount(json);
var buffer = ArrayPool<byte>.Shared.Rent(utf8ByteLength);
try
{
var utf8SpanLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0);
var utf8Span = new ReadOnlySpan<byte>(buffer, 0, utf8SpanLength);
var reader = new Utf8JsonReader(utf8Span, s_allowMultipleValuesJsonReaderOptions);
return JsonSerializer.Deserialize(ref reader, typeInfo);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private T? GetResultCore<T>(JsonSerializerOptions serializerOptions, out FailureReason? failureReason)
{
var json = this.Text;
if (string.IsNullOrEmpty(json))
{
failureReason = FailureReason.ResultDidNotContainJson;
return default;
}
T? deserialized = default;
// If there's an exception here, we want it to propagate, since the Result property is meant to throw directly
deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo<T>)serializerOptions.GetTypeInfo(typeof(T)));
if (deserialized is null)
{
failureReason = FailureReason.DeserializationProducedNull;
return default;
}
failureReason = default;
return deserialized;
}
private enum FailureReason
{
ResultDidNotContainJson,
DeserializationProducedNull
}
}
@@ -0,0 +1,132 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// Represents a single streaming response chunk from an <see cref="Agent"/>.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="AgentRunResponseUpdate"/> is so named because it represents updates
/// that layer on each other to form a single agent response. Conceptually, this combines the roles of
/// <see cref="AgentRunResponse"/> and <see cref="ChatMessage"/> in streaming output.
/// </para>
/// <para>
/// The relationship between <see cref="AgentRunResponse"/> and <see cref="AgentRunResponseUpdate"/> is
/// codified in the <see cref="AgentRunResponseUpdateExtensions.ToAgentRunResponseAsync"/> and
/// <see cref="AgentRunResponse.ToAgentRunResponseUpdates"/>, which enable bidirectional conversions
/// between the two. Note, however, that the provided conversions may be lossy, for example if multiple
/// updates all have different <see cref="RawRepresentation"/> objects whereas there's only one slot for
/// such an object available in <see cref="AgentRunResponse.RawRepresentation"/>.
/// </para>
/// </remarks>
[DebuggerDisplay("[{Role}] {ContentForDebuggerDisplay}{EllipsesForDebuggerDisplay,nq}")]
public class AgentRunResponseUpdate
{
/// <summary>The response update content items.</summary>
private IList<AIContent>? _contents;
/// <summary>The name of the author of the update.</summary>
private string? _authorName;
/// <summary>Initializes a new instance of the <see cref="AgentRunResponseUpdate"/> class.</summary>
[JsonConstructor]
public AgentRunResponseUpdate()
{
}
/// <summary>Initializes a new instance of the <see cref="AgentRunResponseUpdate"/> class.</summary>
/// <param name="role">The role of the author of the update.</param>
/// <param name="content">The text content of the update.</param>
public AgentRunResponseUpdate(ChatRole? role, string? content)
: this(role, content is null ? null : [new TextContent(content)])
{
}
/// <summary>Initializes a new instance of the <see cref="AgentRunResponseUpdate"/> class.</summary>
/// <param name="role">The role of the author of the update.</param>
/// <param name="contents">The contents of the update.</param>
public AgentRunResponseUpdate(ChatRole? role, IList<AIContent>? contents)
{
this.Role = role;
this._contents = contents;
}
/// <summary>Gets or sets the name of the author of the response update.</summary>
public string? AuthorName
{
get => this._authorName;
set => this._authorName = string.IsNullOrWhiteSpace(value) ? null : value;
}
/// <summary>Gets or sets the role of the author of the response update.</summary>
public ChatRole? Role { get; set; }
/// <summary>Gets the text of this update.</summary>
/// <remarks>
/// This property concatenates the text of all <see cref="TextContent"/> objects in <see cref="Contents"/>.
/// </remarks>
[JsonIgnore]
public string Text => this._contents is not null ? this._contents.ConcatText() : string.Empty;
/// <summary>Gets or sets the agent run response update content items.</summary>
[AllowNull]
public IList<AIContent> Contents
{
get => this._contents ??= [];
set => this._contents = value;
}
/// <summary>Gets or sets the raw representation of the response update from an underlying implementation.</summary>
/// <remarks>
/// If a <see cref="AgentRunResponseUpdate"/> is created to represent some underlying object from another object
/// model, this property can be used to store that original object. This can be useful for debugging or
/// for enabling a consumer to access the underlying object model if needed.
/// </remarks>
[JsonIgnore]
public object? RawRepresentation { get; set; }
/// <summary>Gets or sets additional properties for the update.</summary>
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
/// <summary>Gets or sets the ID of the agent that produced the response.</summary>
public string? AgentId { get; set; }
/// <summary>Gets or sets the ID of the response of which this update is a part.</summary>
public string? ResponseId { get; set; }
/// <summary>Gets or sets the ID of the message of which this update is a part.</summary>
/// <remarks>
/// A single streaming response may be composed of multiple messages, each of which may be represented
/// by multiple updates. This property is used to group those updates together into messages.
///
/// Some providers may consider streaming responses to be a single message, and in that case
/// the value of this property may be the same as the response ID.
///
/// This value is used when <see cref="AgentRunResponseUpdateExtensions.ToAgentRunResponseAsync(IAsyncEnumerable{AgentRunResponseUpdate}, System.Threading.CancellationToken)"/>
/// groups <see cref="AgentRunResponseUpdate"/> instances into <see cref="AgentRunResponse"/> instances.
/// The value must be unique to each call to the underlying provider, and must be shared by
/// all updates that are part of the same logical message within a streaming response.
/// </remarks>
public string? MessageId { get; set; }
/// <summary>Gets or sets a timestamp for the response update.</summary>
public DateTimeOffset? CreatedAt { get; set; }
/// <inheritdoc/>
public override string ToString() => this.Text;
/// <summary>Gets a <see cref="AIContent"/> object to display in the debugger display.</summary>
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private AIContent? ContentForDebuggerDisplay => this._contents is { Count: > 0 } ? this._contents[0] : null;
/// <summary>Gets an indication for the debugger display of whether there's more content.</summary>
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string EllipsesForDebuggerDisplay => this._contents is { Count: > 1 } ? ", ..." : string.Empty;
}
@@ -0,0 +1,246 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// Provides extension methods for working with <see cref="AgentRunResponseUpdate"/> instances.
/// </summary>
public static class AgentRunResponseUpdateExtensions
{
/// <summary>Combines <see cref="AgentRunResponseUpdate"/> instances into a single <see cref="AgentRunResponse"/>.</summary>
/// <param name="updates">The updates to be combined.</param>
/// <returns>The combined <see cref="AgentRunResponse"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="updates"/> is <see langword="null"/>.</exception>
/// <remarks>
/// As part of combining <paramref name="updates"/> into a single <see cref="AgentRunResponse"/>, the method will attempt to reconstruct
/// <see cref="ChatMessage"/> instances. This includes using <see cref="AgentRunResponseUpdate.MessageId"/> to determine
/// message boundaries, as well as coalescing contiguous <see cref="AIContent"/> items where applicable, e.g. multiple
/// <see cref="TextContent"/> instances in a row may be combined into a single <see cref="TextContent"/>.
/// </remarks>
public static AgentRunResponse ToAgentRunResponse(
this IEnumerable<AgentRunResponseUpdate> updates)
{
_ = Throw.IfNull(updates);
AgentRunResponse response = new();
foreach (var update in updates)
{
ProcessUpdate(update, response);
}
FinalizeResponse(response);
return response;
}
/// <summary>Combines <see cref="AgentRunResponseUpdate"/> instances into a single <see cref="AgentRunResponse"/>.</summary>
/// <param name="updates">The updates to be combined.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The combined <see cref="AgentRunResponse"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="updates"/> is <see langword="null"/>.</exception>
/// <remarks>
/// As part of combining <paramref name="updates"/> into a single <see cref="AgentRunResponse"/>, the method will attempt to reconstruct
/// <see cref="ChatMessage"/> instances. This includes using <see cref="AgentRunResponseUpdate.MessageId"/> to determine
/// message boundaries, as well as coalescing contiguous <see cref="AIContent"/> items where applicable, e.g. multiple
/// <see cref="TextContent"/> instances in a row may be combined into a single <see cref="TextContent"/>.
/// </remarks>
public static Task<AgentRunResponse> ToAgentRunResponseAsync(
this IAsyncEnumerable<AgentRunResponseUpdate> updates,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(updates);
return ToAgentRunResponseAsync(updates, cancellationToken);
static async Task<AgentRunResponse> ToAgentRunResponseAsync(
IAsyncEnumerable<AgentRunResponseUpdate> updates,
CancellationToken cancellationToken)
{
AgentRunResponse response = new();
await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
ProcessUpdate(update, response);
}
FinalizeResponse(response);
return response;
}
}
/// <summary>Coalesces sequential <see cref="AIContent"/> content elements.</summary>
internal static void CoalesceTextContent(List<AIContent> contents)
{
Coalesce<TextContent>(contents, static text => new(text));
Coalesce<TextReasoningContent>(contents, static text => new(text));
// This implementation relies on TContent's ToString returning its exact text.
static void Coalesce<TContent>(List<AIContent> contents, Func<string, TContent> fromText)
where TContent : AIContent
{
StringBuilder? coalescedText = null;
// Iterate through all of the items in the list looking for contiguous items that can be coalesced.
int start = 0;
while (start < contents.Count - 1)
{
// We need at least two TextContents in a row to be able to coalesce.
if (contents[start] is not TContent firstText)
{
start++;
continue;
}
if (contents[start + 1] is not TContent secondText)
{
start += 2;
continue;
}
// Append the text from those nodes and continue appending subsequent TextContents until we run out.
// We null out nodes as their text is appended so that we can later remove them all in one O(N) operation.
coalescedText ??= new();
_ = coalescedText.Clear().Append(firstText).Append(secondText);
contents[start + 1] = null!;
int i = start + 2;
for (; i < contents.Count && contents[i] is TContent next; i++)
{
_ = coalescedText.Append(next);
contents[i] = null!;
}
// Store the replacement node. We inherit the properties of the first text node. We don't
// currently propagate additional properties from the subsequent nodes. If we ever need to,
// we can add that here.
var newContent = fromText(coalescedText.ToString());
contents[start] = newContent;
newContent.AdditionalProperties = firstText.AdditionalProperties?.Clone();
start = i;
}
// Remove all of the null slots left over from the coalescing process.
_ = contents.RemoveAll(u => u is null);
}
}
/// <summary>Finalizes the <paramref name="response"/> object.</summary>
private static void FinalizeResponse(AgentRunResponse response)
{
int count = response.Messages.Count;
for (int i = 0; i < count; i++)
{
CoalesceTextContent((List<AIContent>)response.Messages[i].Contents);
}
}
/// <summary>Processes the <see cref="AgentRunResponseUpdate"/>, incorporating its contents into <paramref name="response"/>.</summary>
/// <param name="update">The update to process.</param>
/// <param name="response">The <see cref="AgentRunResponse"/> object that should be updated based on <paramref name="update"/>.</param>
private static void ProcessUpdate(AgentRunResponseUpdate update, AgentRunResponse response)
{
// If there is no message created yet, or if the last update we saw had a different
// message ID than the newest update, create a new message.
ChatMessage message;
var isNewMessage = false;
if (response.Messages.Count == 0)
{
isNewMessage = true;
}
else if (update.MessageId is { Length: > 0 } updateMessageId
&& response.Messages[response.Messages.Count - 1].MessageId is string lastMessageId
&& updateMessageId != lastMessageId)
{
isNewMessage = true;
}
if (isNewMessage)
{
message = new ChatMessage(ChatRole.Assistant, []);
response.Messages.Add(message);
}
else
{
message = response.Messages[response.Messages.Count - 1];
}
// Some members on AgentRunResponseUpdate map to members of ChatMessage.
// Incorporate those into the latest message; in cases where the message
// stores a single value, prefer the latest update's value over anything
// stored in the message.
if (update.AuthorName is not null)
{
message.AuthorName = update.AuthorName;
}
if (update.Role is ChatRole role)
{
message.Role = role;
}
if (update.MessageId is { Length: > 0 })
{
// Note that this must come after the message checks earlier, as they depend
// on this value for change detection.
message.MessageId = update.MessageId;
}
foreach (var content in update.Contents)
{
switch (content)
{
// Usage content is treated specially and propagated to the response's Usage.
case UsageContent usage:
(response.Usage ??= new()).Add(usage.Details);
break;
default:
message.Contents.Add(content);
break;
}
}
// Other members on a AgentRunResponseUpdate map to members of the AgentRunResponse.
// Update the response object with those, preferring the values from later updates.
if (update.AgentId is { Length: > 0 })
{
response.AgentId = update.AgentId;
}
if (update.ResponseId is { Length: > 0 })
{
response.ResponseId = update.ResponseId;
}
if (update.CreatedAt is not null)
{
response.CreatedAt = update.CreatedAt;
}
if (update.AdditionalProperties is not null)
{
if (response.AdditionalProperties is null)
{
response.AdditionalProperties = new(update.AdditionalProperties);
}
else
{
foreach (var item in update.AdditionalProperties)
{
response.AdditionalProperties[item.Key] = item.Value;
}
}
}
}
}
@@ -12,7 +12,7 @@ namespace Microsoft.Extensions.AI.Agents.CopilotStudio;
/// </summary>
internal static class ActivityProcessor
{
public static async IAsyncEnumerable<(ChatMessage message, bool reasoning)> ProcessActivityAsync(IAsyncEnumerable<IActivity> activities, bool streaming, ILogger logger)
public static async IAsyncEnumerable<ChatMessage> ProcessActivityAsync(IAsyncEnumerable<IActivity> activities, bool streaming, ILogger logger)
{
await foreach (IActivity activity in activities.ConfigureAwait(false))
{
@@ -27,13 +27,13 @@ internal static class ActivityProcessor
// pick from a list of actions.
// The activity text doesn't make sense without the actions, as the message
// is often instructing the user to pick from the provided list of actions.
yield return (CreateChatMessageFromActivity(activity, [new TextContent(activity.Text)]), false);
yield return CreateChatMessageFromActivity(activity, [new TextContent(activity.Text)]);
break;
case "typing":
case "event":
// TODO: Revisit usage of TextReasoningContent here, to evaluate whether all are really reasoning
// or whether simply an AIContent base type would be more appropriate.
yield return (CreateChatMessageFromActivity(activity, [new TextReasoningContent(activity.Text)]), true);
yield return CreateChatMessageFromActivity(activity, [new TextReasoningContent(activity.Text)]);
break;
default:
logger.LogWarning("Unknown activity type '{ActivityType}' received.", activity.Type);
@@ -44,7 +44,7 @@ public class CopilotStudioAgent : Agent
}
/// <inheritdoc/>
public override async Task<ChatResponse> RunAsync(
public override async Task<AgentRunResponse> RunAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -63,39 +63,24 @@ public class CopilotStudioAgent : Agent
// Invoke the Copilot Studio agent with the provided messages.
string question = string.Join("\n", messages.Select(m => m.Text));
var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, copilotStudioAgentThread.Id, cancellationToken), streaming: false, this._logger);
// Enumerate the response messages
var responseMessagesList = new List<ChatMessage>();
await foreach ((ChatMessage message, bool reasoning) in responseMessages.ConfigureAwait(false))
await foreach (var message in responseMessages.ConfigureAwait(false))
{
// If the message is a reasoning message, return it as part of the intermediate messages
// instead of the final response.
if (reasoning)
{
if (options?.OnIntermediateMessages is not null)
{
await options.OnIntermediateMessages.Invoke([message]).ConfigureAwait(false);
}
continue;
}
// Add the message to the list
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 ChatResponse(responseMessagesList)
return new AgentRunResponse(responseMessagesList)
{
AgentId = this.Id,
ResponseId = responseMessagesList.LastOrDefault()?.MessageId,
ConversationId = copilotStudioAgentThread.Id,
};
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -116,25 +101,19 @@ public class CopilotStudioAgent : Agent
var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, copilotStudioAgentThread.Id, cancellationToken), streaming: true, this._logger);
// Enumerate the response messages
await foreach ((ChatMessage message, bool reasoning) in responseMessages.ConfigureAwait(false))
await foreach (ChatMessage message in responseMessages.ConfigureAwait(false))
{
// If the message is a reasoning message, return it as part of the intermediate messages.
if (reasoning && options?.OnIntermediateMessages is not null)
{
await options.OnIntermediateMessages.Invoke([message]).ConfigureAwait(false);
}
// 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 ChatResponseUpdate(message.Role, message.Contents)
yield return new AgentRunResponseUpdate(message.Role, message.Contents)
{
AgentId = this.Id,
AdditionalProperties = message.AdditionalProperties,
AuthorName = message.AuthorName,
RawRepresentation = message.RawRepresentation,
ResponseId = message.MessageId,
MessageId = message.MessageId,
ConversationId = copilotStudioAgentThread.Id,
};
}
}
@@ -67,7 +67,7 @@ public sealed class ChatClientAgent : Agent
internal ChatOptions? ChatOptions => this._agentOptions?.ChatOptions;
/// <inheritdoc/>
public override async Task<ChatResponse> RunAsync(
public override async Task<AgentRunResponse> RunAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -103,16 +103,12 @@ public sealed class ChatClientAgent : Agent
var chatResponseMessages = chatResponse.Messages as IReadOnlyCollection<ChatMessage> ?? chatResponse.Messages.ToArray();
await this.NotifyThreadOfNewMessagesAsync(chatClientThread, chatResponseMessages, cancellationToken).ConfigureAwait(false);
if (options?.OnIntermediateMessages is not null)
{
await options.OnIntermediateMessages(chatResponseMessages).ConfigureAwait(false);
}
return chatResponse;
return chatResponse.ToAgentRunResponse(this.Id);
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -145,7 +141,7 @@ public sealed class ChatClientAgent : Agent
{
responseUpdates.Add(update);
update.AuthorName ??= agentName;
yield return update;
yield return update.ToAgentRunResponseUpdate(this.Id);
}
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
@@ -162,10 +158,6 @@ public sealed class ChatClientAgent : Agent
await this.NotifyThreadOfNewMessagesAsync(chatClientThread, inputMessages, cancellationToken).ConfigureAwait(false);
await this.NotifyThreadOfNewMessagesAsync(chatClientThread, chatResponseMessages, cancellationToken).ConfigureAwait(false);
if (options?.OnIntermediateMessages is not null)
{
await options.OnIntermediateMessages(chatResponseMessages).ConfigureAwait(false);
}
}
/// <inheritdoc/>
@@ -21,8 +21,8 @@ public static class ChatClientAgentExtensions
/// <param name="agentRunOptions">Optional parameters for agent invocation.</param>
/// <param name="chatOptions">Optional chat options.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public static Task<ChatResponse> RunAsync(
/// <returns>A <see cref="AgentRunResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public static Task<AgentRunResponse> RunAsync(
this ChatClientAgent agent,
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
@@ -45,8 +45,8 @@ public static class ChatClientAgentExtensions
/// <param name="agentRunOptions">Optional parameters for agent invocation.</param>
/// <param name="chatOptions">Optional chat options.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public static Task<ChatResponse> RunAsync(
/// <returns>A <see cref="AgentRunResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public static Task<AgentRunResponse> RunAsync(
this ChatClientAgent agent,
string prompt,
AgentThread? thread = null,
@@ -69,7 +69,7 @@ public static class ChatClientAgentExtensions
/// <param name="agentRunOptions">Optional parameters for agent invocation.</param>
/// <param name="chatOptions">Optional chat options.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
public static IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
public static IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
this ChatClientAgent agent,
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
@@ -92,8 +92,8 @@ public static class ChatClientAgentExtensions
/// <param name="agentRunOptions">Optional parameters for agent invocation.</param>
/// <param name="chatOptions">Optional chat options.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async enumerable of <see cref="ChatResponseUpdate"/> items for streaming the response.</returns>
public static IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
/// <returns>An async enumerable of <see cref="AgentRunResponseUpdate"/> items for streaming the response.</returns>
public static IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
this ChatClientAgent agent,
string prompt,
AgentThread? thread = null,
@@ -14,7 +14,6 @@ internal sealed class ChatClientAgentRunOptions : AgentRunOptions
/// <param name="chatOptions">Optional chat options to pass to the agent's invocation.</param>
internal ChatClientAgentRunOptions(AgentRunOptions? source = null, ChatOptions? chatOptions = null)
{
this.OnIntermediateMessages = source?.OnIntermediateMessages;
this.ChatOptions = chatOptions;
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// Contains extension methods for <see cref="ChatResponse"/> and <see cref="ChatResponseUpdate"/>.
/// </summary>
internal static class ChatResponseExtensions
{
/// <summary>
/// Converts a <see cref="ChatResponse"/> instance to an <see cref="AgentRunResponse"/>.
/// </summary>
/// <param name="chatResponse">The <see cref="ChatResponse"/> to convert. Cannot be <see langword="null"/>.</param>
/// <param name="agentId">The ID of the agent that generated the response. Cannot be <see langword="null"/>.</param>
/// <returns>
/// An <see cref="AgentRunResponse"/> containing the messages, metadata, and additional properties from the
/// specified <see cref="ChatResponse"/>.
/// </returns>
public static AgentRunResponse ToAgentRunResponse(this ChatResponse chatResponse, string agentId)
{
_ = Throw.IfNull(chatResponse);
_ = Throw.IfNullOrWhitespace(agentId);
return new AgentRunResponse(chatResponse.Messages)
{
AgentId = agentId,
ResponseId = chatResponse.ResponseId,
CreatedAt = chatResponse.CreatedAt,
Usage = chatResponse.Usage,
RawRepresentation = chatResponse,
AdditionalProperties = chatResponse.AdditionalProperties
};
}
/// <summary>
/// Converts a <see cref="ChatResponseUpdate"/> instance to an <see cref="AgentRunResponseUpdate"/>.
/// </summary>
/// <param name="chatResponseUpdate">The <see cref="ChatResponseUpdate"/> to convert. Cannot be <see langword="null"/>.</param>
/// <param name="agentId">The ID of the agent that generated the response. Cannot be <see langword="null"/>.</param>
/// <returns>An <see cref="AgentRunResponseUpdate"/> containing the properties from the specified <see cref="ChatResponseUpdate"/>.</returns>
public static AgentRunResponseUpdate ToAgentRunResponseUpdate(this ChatResponseUpdate chatResponseUpdate, string agentId)
{
_ = Throw.IfNull(chatResponseUpdate);
return new()
{
AgentId = agentId,
Role = chatResponseUpdate.Role,
AuthorName = chatResponseUpdate.AuthorName,
Contents = chatResponseUpdate.Contents,
MessageId = chatResponseUpdate.MessageId,
ResponseId = chatResponseUpdate.ResponseId,
CreatedAt = chatResponseUpdate.CreatedAt,
RawRepresentation = chatResponseUpdate,
AdditionalProperties = chatResponseUpdate.AdditionalProperties
};
}
}
+11 -10
View File
@@ -4,6 +4,7 @@ using System.Reflection;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Samples;
@@ -87,29 +88,29 @@ public abstract class BaseSample : TextWriter
/// Processes and writes the latest agent chat response to the console, including metadata and content details.
/// </summary>
/// <remarks>This method formats and outputs the most recent message from the provided <see
/// cref="ChatResponse"/> object. It includes the message role, author name (if available), text content, and
/// cref="AgentRunResponse"/> object. It includes the message role, author name (if available), text content, and
/// additional content such as images, function calls, and function results. Usage statistics, including token
/// counts, are also displayed.</remarks>
/// <param name="chatResponse">The <see cref="ChatResponse"/> object containing the chat messages and usage data.</param>
/// <param name="response">The <see cref="AgentRunResponse"/> object containing the chat messages and usage data.</param>
/// <param name="printUsage">The flag to indicate whether to print usage information. Defaults to <see langword="true"/>.</param>
protected void WriteResponseOutput(ChatResponse chatResponse, bool? printUsage = true)
protected void WriteResponseOutput(AgentRunResponse response, bool? printUsage = true)
{
if (chatResponse.Messages.Count == 0)
if (response.Messages.Count == 0)
{
// If there are no messages, we can skip writing the message.
return;
}
var message = chatResponse.Messages.Last();
var message = response.Messages.Last();
this.WriteMessageOutput(message);
WriteUsage();
void WriteUsage()
{
if (!(printUsage ?? true) || chatResponse.Usage is null) { return; }
if (!(printUsage ?? true) || response.Usage is null) { return; }
UsageDetails usageDetails = chatResponse.Usage;
UsageDetails usageDetails = response.Usage;
Console.WriteLine($" [Usage] Tokens: {usageDetails.TotalTokenCount}, Input: {usageDetails.InputTokenCount}, Output: {usageDetails.OutputTokenCount}");
}
@@ -151,11 +152,11 @@ public abstract class BaseSample : TextWriter
/// Writes the streaming agent response updates to the console.
/// </summary>
/// <remarks>This method formats and outputs the most recent message from the provided <see
/// cref="ChatResponseUpdate"/> object. It includes the message role, author name (if available), text content, and
/// cref="AgentRunResponseUpdate"/> object. It includes the message role, author name (if available), text content, and
/// additional content such as images, function calls, and function results. Usage statistics, including token
/// counts, are also displayed.</remarks>
/// <param name="update">The <see cref="ChatResponseUpdate"/> object containing the chat messages and usage data.</param>
protected void WriteAgentOutput(ChatResponseUpdate update)
/// <param name="update">The <see cref="AgentRunResponseUpdate"/> object containing the chat messages and usage data.</param>
protected void WriteAgentOutput(AgentRunResponseUpdate update)
{
if (update.Contents.Count == 0)
{
@@ -74,7 +74,7 @@ public abstract class OrchestrationSample : BaseSample
}
/// <summary>
/// Writes the provided chat response messages to the console or test output, including role and author information.
/// Writes the provided messages to the console or test output, including role and author information.
/// </summary>
/// <param name="response">An enumerable of <see cref="ChatMessage"/> objects to write.</param>
protected static void WriteResponse(IEnumerable<ChatMessage> response)
@@ -89,15 +89,15 @@ public abstract class OrchestrationSample : BaseSample
}
/// <summary>
/// Writes the streamed chat response updates to the console or test output, including role and author information.
/// Writes the streamed agent run response updates to the console or test output, including role and author information.
/// </summary>
/// <param name="streamedResponses">An enumerable of <see cref="ChatResponseUpdate"/> objects representing streamed responses.</param>
protected static void WriteStreamedResponse(IEnumerable<ChatResponseUpdate> streamedResponses)
/// <param name="streamedResponses">An enumerable of <see cref="AgentRunResponseUpdate"/> objects representing streamed responses.</param>
protected static void WriteStreamedResponse(IEnumerable<AgentRunResponseUpdate> streamedResponses)
{
string? authorName = null;
ChatRole? authorRole = null;
StringBuilder builder = new();
foreach (ChatResponseUpdate response in streamedResponses)
foreach (AgentRunResponseUpdate response in streamedResponses)
{
authorName ??= response.AuthorName;
authorRole ??= response.Role;
@@ -122,7 +122,7 @@ public abstract class OrchestrationSample : BaseSample
/// <summary>
/// Gets the list of streamed response updates received so far.
/// </summary>
public List<ChatResponseUpdate> StreamedResponses { get; } = [];
public List<AgentRunResponseUpdate> StreamedResponses { get; } = [];
/// <summary>
/// Gets the list of chat messages representing the conversation history.
@@ -142,12 +142,12 @@ public abstract class OrchestrationSample : BaseSample
}
/// <summary>
/// Callback to handle a streamed chat response update, adding it to the list and writing output if final.
/// Callback to handle a streamed agent run response update, adding it to the list and writing output if final.
/// </summary>
/// <param name="streamedResponse">The <see cref="ChatResponseUpdate"/> to process.</param>
/// <param name="streamedResponse">The <see cref="AgentRunResponseUpdate"/> to process.</param>
/// <param name="isFinal">Indicates whether this is the final update in the stream.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public ValueTask StreamingResultCallback(ChatResponseUpdate streamedResponse, bool isFinal)
public ValueTask StreamingResultCallback(AgentRunResponseUpdate streamedResponse, bool isFinal)
{
this.StreamedResponses.Add(streamedResponse);