Skip to content

Commit 444e5cb

Browse files
authored
Add TextReasoningContent (#6222)
* Add TextReasoningContent * Coalesce TextReasoningContent
1 parent aaa1a25 commit 444e5cb

File tree

5 files changed

+180
-38
lines changed

5 files changed

+180
-38
lines changed

src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs

Lines changed: 45 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -180,53 +180,60 @@ static async Task<ChatResponse> ToChatResponseAsync(
180180
}
181181
}
182182

183-
/// <summary>Coalesces sequential <see cref="TextContent"/> content elements.</summary>
183+
/// <summary>Coalesces sequential <see cref="AIContent"/> content elements.</summary>
184184
internal static void CoalesceTextContent(List<AIContent> contents)
185185
{
186-
StringBuilder? coalescedText = null;
186+
Coalesce<TextContent>(contents, static text => new(text));
187+
Coalesce<TextReasoningContent>(contents, static text => new(text));
187188

188-
// Iterate through all of the items in the list looking for contiguous items that can be coalesced.
189-
int start = 0;
190-
while (start < contents.Count - 1)
189+
// This implementation relies on TContent's ToString returning its exact text.
190+
static void Coalesce<TContent>(List<AIContent> contents, Func<string, TContent> fromText)
191+
where TContent : AIContent
191192
{
192-
// We need at least two TextContents in a row to be able to coalesce.
193-
if (contents[start] is not TextContent firstText)
194-
{
195-
start++;
196-
continue;
197-
}
198-
199-
if (contents[start + 1] is not TextContent secondText)
200-
{
201-
start += 2;
202-
continue;
203-
}
193+
StringBuilder? coalescedText = null;
204194

205-
// Append the text from those nodes and continue appending subsequent TextContents until we run out.
206-
// We null out nodes as their text is appended so that we can later remove them all in one O(N) operation.
207-
coalescedText ??= new();
208-
_ = coalescedText.Clear().Append(firstText.Text).Append(secondText.Text);
209-
contents[start + 1] = null!;
210-
int i = start + 2;
211-
for (; i < contents.Count && contents[i] is TextContent next; i++)
195+
// Iterate through all of the items in the list looking for contiguous items that can be coalesced.
196+
int start = 0;
197+
while (start < contents.Count - 1)
212198
{
213-
_ = coalescedText.Append(next.Text);
214-
contents[i] = null!;
199+
// We need at least two TextContents in a row to be able to coalesce.
200+
if (contents[start] is not TContent firstText)
201+
{
202+
start++;
203+
continue;
204+
}
205+
206+
if (contents[start + 1] is not TContent secondText)
207+
{
208+
start += 2;
209+
continue;
210+
}
211+
212+
// Append the text from those nodes and continue appending subsequent TextContents until we run out.
213+
// We null out nodes as their text is appended so that we can later remove them all in one O(N) operation.
214+
coalescedText ??= new();
215+
_ = coalescedText.Clear().Append(firstText).Append(secondText);
216+
contents[start + 1] = null!;
217+
int i = start + 2;
218+
for (; i < contents.Count && contents[i] is TContent next; i++)
219+
{
220+
_ = coalescedText.Append(next);
221+
contents[i] = null!;
222+
}
223+
224+
// Store the replacement node. We inherit the properties of the first text node. We don't
225+
// currently propagate additional properties from the subsequent nodes. If we ever need to,
226+
// we can add that here.
227+
var newContent = fromText(coalescedText.ToString());
228+
contents[start] = newContent;
229+
newContent.AdditionalProperties = firstText.AdditionalProperties?.Clone();
230+
231+
start = i;
215232
}
216233

217-
// Store the replacement node.
218-
contents[start] = new TextContent(coalescedText.ToString())
219-
{
220-
// We inherit the properties of the first text node. We don't currently propagate additional
221-
// properties from the subsequent nodes. If we ever need to, we can add that here.
222-
AdditionalProperties = firstText.AdditionalProperties?.Clone(),
223-
};
224-
225-
start = i;
234+
// Remove all of the null slots left over from the coalescing process.
235+
_ = contents.RemoveAll(u => u is null);
226236
}
227-
228-
// Remove all of the null slots left over from the coalescing process.
229-
_ = contents.RemoveAll(u => u is null);
230237
}
231238

232239
/// <summary>Finalizes the <paramref name="response"/> object.</summary>

src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ namespace Microsoft.Extensions.AI;
1212
[JsonDerivedType(typeof(FunctionCallContent), typeDiscriminator: "functionCall")]
1313
[JsonDerivedType(typeof(FunctionResultContent), typeDiscriminator: "functionResult")]
1414
[JsonDerivedType(typeof(TextContent), typeDiscriminator: "text")]
15+
[JsonDerivedType(typeof(TextReasoningContent), typeDiscriminator: "reasoning")]
1516
[JsonDerivedType(typeof(UriContent), typeDiscriminator: "uri")]
1617
[JsonDerivedType(typeof(UsageContent), typeDiscriminator: "usage")]
1718
public class AIContent
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
using System.Diagnostics.CodeAnalysis;
6+
7+
namespace Microsoft.Extensions.AI;
8+
9+
/// <summary>
10+
/// Represents text reasoning content in a chat.
11+
/// </summary>
12+
/// <remarks>
13+
/// <see cref="TextReasoningContent"/> is distinct from <see cref="TextContent"/>. <see cref="TextReasoningContent"/>
14+
/// represents "thinking" or "reasoning" performed by the model and is distinct from the actual output text from
15+
/// the model, which is represented by <see cref="TextContent"/>. Neither types derives from the other.
16+
/// </remarks>
17+
[DebuggerDisplay("{DebuggerDisplay,nq}")]
18+
public sealed class TextReasoningContent : AIContent
19+
{
20+
private string? _text;
21+
22+
/// <summary>
23+
/// Initializes a new instance of the <see cref="TextReasoningContent"/> class.
24+
/// </summary>
25+
/// <param name="text">The text reasoning content.</param>
26+
public TextReasoningContent(string? text)
27+
{
28+
_text = text;
29+
}
30+
31+
/// <summary>
32+
/// Gets or sets the text reasoning content.
33+
/// </summary>
34+
[AllowNull]
35+
public string Text
36+
{
37+
get => _text ?? string.Empty;
38+
set => _text = value;
39+
}
40+
41+
/// <inheritdoc/>
42+
public override string ToString() => Text;
43+
44+
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
45+
private string DebuggerDisplay => $"Reasoning = \"{Text}\"";
46+
}

test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,44 @@ void AddGap()
145145
}
146146
}
147147

148+
[Theory]
149+
[InlineData(false)]
150+
[InlineData(true)]
151+
public async Task ToChatResponse_CoalescesTextContentAndTextReasoningContentSeparately(bool useAsync)
152+
{
153+
ChatResponseUpdate[] updates =
154+
{
155+
new(null, "A"),
156+
new(null, "B"),
157+
new(null, "C"),
158+
new() { Contents = [new TextReasoningContent("D")] },
159+
new() { Contents = [new TextReasoningContent("E")] },
160+
new() { Contents = [new TextReasoningContent("F")] },
161+
new(null, "G"),
162+
new(null, "H"),
163+
new() { Contents = [new TextReasoningContent("I")] },
164+
new() { Contents = [new TextReasoningContent("J")] },
165+
new(null, "K"),
166+
new() { Contents = [new TextReasoningContent("L")] },
167+
new(null, "M"),
168+
new(null, "N"),
169+
new() { Contents = [new TextReasoningContent("O")] },
170+
new() { Contents = [new TextReasoningContent("P")] },
171+
};
172+
173+
ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse();
174+
ChatMessage message = Assert.Single(response.Messages);
175+
Assert.Equal(8, message.Contents.Count);
176+
Assert.Equal("ABC", Assert.IsType<TextContent>(message.Contents[0]).Text);
177+
Assert.Equal("DEF", Assert.IsType<TextReasoningContent>(message.Contents[1]).Text);
178+
Assert.Equal("GH", Assert.IsType<TextContent>(message.Contents[2]).Text);
179+
Assert.Equal("IJ", Assert.IsType<TextReasoningContent>(message.Contents[3]).Text);
180+
Assert.Equal("K", Assert.IsType<TextContent>(message.Contents[4]).Text);
181+
Assert.Equal("L", Assert.IsType<TextReasoningContent>(message.Contents[5]).Text);
182+
Assert.Equal("MN", Assert.IsType<TextContent>(message.Contents[6]).Text);
183+
Assert.Equal("OP", Assert.IsType<TextReasoningContent>(message.Contents[7]).Text);
184+
}
185+
148186
[Fact]
149187
public async Task ToChatResponse_UsageContentExtractedFromContents()
150188
{
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Xunit;
5+
6+
namespace Microsoft.Extensions.AI;
7+
8+
public class TextReasoningContentTests
9+
{
10+
[Theory]
11+
[InlineData(null)]
12+
[InlineData("")]
13+
[InlineData("text")]
14+
public void Constructor_String_PropsDefault(string? text)
15+
{
16+
TextReasoningContent c = new(text);
17+
Assert.Null(c.RawRepresentation);
18+
Assert.Null(c.AdditionalProperties);
19+
Assert.Equal(text ?? string.Empty, c.Text);
20+
}
21+
22+
[Fact]
23+
public void Constructor_PropsRoundtrip()
24+
{
25+
TextReasoningContent c = new(null);
26+
27+
Assert.Null(c.RawRepresentation);
28+
object raw = new();
29+
c.RawRepresentation = raw;
30+
Assert.Same(raw, c.RawRepresentation);
31+
32+
Assert.Null(c.AdditionalProperties);
33+
AdditionalPropertiesDictionary props = new() { { "key", "value" } };
34+
c.AdditionalProperties = props;
35+
Assert.Same(props, c.AdditionalProperties);
36+
37+
Assert.Equal(string.Empty, c.Text);
38+
c.Text = "text";
39+
Assert.Equal("text", c.Text);
40+
Assert.Equal("text", c.ToString());
41+
42+
c.Text = null;
43+
Assert.Equal(string.Empty, c.Text);
44+
Assert.Equal(string.Empty, c.ToString());
45+
46+
c.Text = string.Empty;
47+
Assert.Equal(string.Empty, c.Text);
48+
Assert.Equal(string.Empty, c.ToString());
49+
}
50+
}

0 commit comments

Comments
 (0)