diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs index 01cdb8dc322..bf73ab86934 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs @@ -180,53 +180,60 @@ static async Task ToChatResponseAsync( } } - /// Coalesces sequential content elements. + /// Coalesces sequential content elements. internal static void CoalesceTextContent(List contents) { - StringBuilder? coalescedText = null; + Coalesce(contents, static text => new(text)); + Coalesce(contents, static text => new(text)); - // 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) + // This implementation relies on TContent's ToString returning its exact text. + static void Coalesce(List contents, Func fromText) + where TContent : AIContent { - // We need at least two TextContents in a row to be able to coalesce. - if (contents[start] is not TextContent firstText) - { - start++; - continue; - } - - if (contents[start + 1] is not TextContent secondText) - { - start += 2; - continue; - } + StringBuilder? coalescedText = null; - // 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.Text).Append(secondText.Text); - contents[start + 1] = null!; - int i = start + 2; - for (; i < contents.Count && contents[i] is TextContent next; i++) + // 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) { - _ = coalescedText.Append(next.Text); - contents[i] = null!; + // 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; } - // Store the replacement node. - contents[start] = new TextContent(coalescedText.ToString()) - { - // 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. - AdditionalProperties = firstText.AdditionalProperties?.Clone(), - }; - - start = i; + // Remove all of the null slots left over from the coalescing process. + _ = contents.RemoveAll(u => u is null); } - - // Remove all of the null slots left over from the coalescing process. - _ = contents.RemoveAll(u => u is null); } /// Finalizes the object. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs index 068bd1ce447..25a21af5f16 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs @@ -12,6 +12,7 @@ namespace Microsoft.Extensions.AI; [JsonDerivedType(typeof(FunctionCallContent), typeDiscriminator: "functionCall")] [JsonDerivedType(typeof(FunctionResultContent), typeDiscriminator: "functionResult")] [JsonDerivedType(typeof(TextContent), typeDiscriminator: "text")] +[JsonDerivedType(typeof(TextReasoningContent), typeDiscriminator: "reasoning")] [JsonDerivedType(typeof(UriContent), typeDiscriminator: "uri")] [JsonDerivedType(typeof(UsageContent), typeDiscriminator: "usage")] public class AIContent diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextReasoningContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextReasoningContent.cs new file mode 100644 index 00000000000..ccf84af2e3d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextReasoningContent.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents text reasoning content in a chat. +/// +/// +/// is distinct from . +/// represents "thinking" or "reasoning" performed by the model and is distinct from the actual output text from +/// the model, which is represented by . Neither types derives from the other. +/// +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class TextReasoningContent : AIContent +{ + private string? _text; + + /// + /// Initializes a new instance of the class. + /// + /// The text reasoning content. + public TextReasoningContent(string? text) + { + _text = text; + } + + /// + /// Gets or sets the text reasoning content. + /// + [AllowNull] + public string Text + { + get => _text ?? string.Empty; + set => _text = value; + } + + /// + public override string ToString() => Text; + + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay => $"Reasoning = \"{Text}\""; +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs index 35113fc640a..50c4d136017 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs @@ -145,6 +145,44 @@ void AddGap() } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_CoalescesTextContentAndTextReasoningContentSeparately(bool useAsync) + { + ChatResponseUpdate[] updates = + { + new(null, "A"), + new(null, "B"), + new(null, "C"), + new() { Contents = [new TextReasoningContent("D")] }, + new() { Contents = [new TextReasoningContent("E")] }, + new() { Contents = [new TextReasoningContent("F")] }, + new(null, "G"), + new(null, "H"), + new() { Contents = [new TextReasoningContent("I")] }, + new() { Contents = [new TextReasoningContent("J")] }, + new(null, "K"), + new() { Contents = [new TextReasoningContent("L")] }, + new(null, "M"), + new(null, "N"), + new() { Contents = [new TextReasoningContent("O")] }, + new() { Contents = [new TextReasoningContent("P")] }, + }; + + ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(8, message.Contents.Count); + Assert.Equal("ABC", Assert.IsType(message.Contents[0]).Text); + Assert.Equal("DEF", Assert.IsType(message.Contents[1]).Text); + Assert.Equal("GH", Assert.IsType(message.Contents[2]).Text); + Assert.Equal("IJ", Assert.IsType(message.Contents[3]).Text); + Assert.Equal("K", Assert.IsType(message.Contents[4]).Text); + Assert.Equal("L", Assert.IsType(message.Contents[5]).Text); + Assert.Equal("MN", Assert.IsType(message.Contents[6]).Text); + Assert.Equal("OP", Assert.IsType(message.Contents[7]).Text); + } + [Fact] public async Task ToChatResponse_UsageContentExtractedFromContents() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextReasoningContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextReasoningContentTests.cs new file mode 100644 index 00000000000..9d2e238a068 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextReasoningContentTests.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class TextReasoningContentTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("text")] + public void Constructor_String_PropsDefault(string? text) + { + TextReasoningContent c = new(text); + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + Assert.Equal(text ?? string.Empty, c.Text); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + TextReasoningContent c = new(null); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + + Assert.Equal(string.Empty, c.Text); + c.Text = "text"; + Assert.Equal("text", c.Text); + Assert.Equal("text", c.ToString()); + + c.Text = null; + Assert.Equal(string.Empty, c.Text); + Assert.Equal(string.Empty, c.ToString()); + + c.Text = string.Empty; + Assert.Equal(string.Empty, c.Text); + Assert.Equal(string.Empty, c.ToString()); + } +}