mirror of
https://github.com/microsoft/semantic-kernel.git
synced 2026-10-12 02:19:06 +00:00
Add IncludeThoughts parameter to Google Connector for accessing Gemini reasoning content (#13383)
### Motivation and Context <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> This implements the IncludeThoughts parameter for the Google connector. See [related issue here](https://github.com/microsoft/semantic-kernel/issues/13360). ### Description Adds `IncludeThoughts` parameter to `GeminiThinkingConfig` to enable access to Gemini's reasoning process. When enabled, thinking content is exposed via the `Items` collection as `ReasoningContent` objects, allowing developers to access both the model's internal reasoning and final response separately. ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [X] I didn't break anyone 😄 --------- Co-authored-by: Roger Barreto <[email protected]> Co-authored-by: Mark Wallace <[email protected]>
This commit is contained in:
co-authored by
Roger Barreto
Mark Wallace
parent
b50299be6d
commit
42716d5e9a
+150
@@ -9,6 +9,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.SemanticKernel;
|
||||
using Microsoft.SemanticKernel.ChatCompletion;
|
||||
using Microsoft.SemanticKernel.Connectors.Google;
|
||||
using Microsoft.SemanticKernel.Connectors.Google.Core;
|
||||
@@ -597,6 +598,155 @@ public sealed class GeminiChatGenerationTests : IDisposable
|
||||
public ValueTask<string> GetBearerToken() => ValueTask.FromResult(this.BearerKeys[this._index++]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldHandleThoughtAndTextPartsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var responseContent = """
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "Let me think about this...",
|
||||
"thought": true
|
||||
},
|
||||
{
|
||||
"text": "The answer is 42."
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
this._messageHandlerStub.ResponseToReturn.Content = new StringContent(responseContent);
|
||||
var client = this.CreateChatCompletionClient();
|
||||
var chatHistory = CreateSampleChatHistory();
|
||||
|
||||
// Act
|
||||
var result = await client.GenerateChatMessageAsync(chatHistory);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
var message = result[0];
|
||||
Assert.Equal("The answer is 42.", message.Content);
|
||||
Assert.Equal(2, message.Items.Count);
|
||||
|
||||
#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
var reasoningContent = message.Items.OfType<ReasoningContent>().FirstOrDefault();
|
||||
Assert.NotNull(reasoningContent);
|
||||
Assert.Equal("Let me think about this...", reasoningContent.Text);
|
||||
|
||||
var textContent = message.Items.OfType<TextContent>().FirstOrDefault();
|
||||
Assert.NotNull(textContent);
|
||||
Assert.Equal("The answer is 42.", textContent.Text);
|
||||
#pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldHandleOnlyThoughtPartsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var responseContent = """
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "This is just thinking content...",
|
||||
"thought": true
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
this._messageHandlerStub.ResponseToReturn.Content = new StringContent(responseContent);
|
||||
var client = this.CreateChatCompletionClient();
|
||||
var chatHistory = CreateSampleChatHistory();
|
||||
|
||||
// Act
|
||||
var result = await client.GenerateChatMessageAsync(chatHistory);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
var message = result[0];
|
||||
Assert.True(string.IsNullOrEmpty(message.Content));
|
||||
Assert.Single(message.Items);
|
||||
|
||||
#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
var reasoningContent = message.Items.OfType<ReasoningContent>().Single();
|
||||
Assert.Equal("This is just thinking content...", reasoningContent.Text);
|
||||
#pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldHandleMultipleThoughtPartsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var responseContent = """
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "First thought...",
|
||||
"thought": true
|
||||
},
|
||||
{
|
||||
"text": "Second thought...",
|
||||
"thought": true
|
||||
},
|
||||
{
|
||||
"text": "Final answer."
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
this._messageHandlerStub.ResponseToReturn.Content = new StringContent(responseContent);
|
||||
var client = this.CreateChatCompletionClient();
|
||||
var chatHistory = CreateSampleChatHistory();
|
||||
|
||||
// Act
|
||||
var result = await client.GenerateChatMessageAsync(chatHistory);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
var message = result[0];
|
||||
Assert.Equal("Final answer.", message.Content);
|
||||
Assert.Equal(3, message.Items.Count);
|
||||
|
||||
#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
var reasoningContents = message.Items.OfType<ReasoningContent>().ToList();
|
||||
Assert.Equal(2, reasoningContents.Count);
|
||||
Assert.Equal("First thought...", reasoningContents[0].Text);
|
||||
Assert.Equal("Second thought...", reasoningContents[1].Text);
|
||||
|
||||
var textContent = message.Items.OfType<TextContent>().Single();
|
||||
Assert.Equal("Final answer.", textContent.Text);
|
||||
#pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
}
|
||||
|
||||
private static ChatHistory CreateSampleChatHistory()
|
||||
{
|
||||
var chatHistory = new ChatHistory();
|
||||
|
||||
+41
@@ -7,6 +7,7 @@ using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.SemanticKernel;
|
||||
using Microsoft.SemanticKernel.ChatCompletion;
|
||||
using Microsoft.SemanticKernel.Connectors.Google;
|
||||
using Microsoft.SemanticKernel.Connectors.Google.Core;
|
||||
@@ -428,6 +429,46 @@ public sealed class GeminiChatStreamingTests : IDisposable
|
||||
Assert.DoesNotContain("key=", this._messageHandlerStub.RequestUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldHandleStreamingThoughtPartsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var streamingResponse = """
|
||||
data: {"candidates": [{"content": {"parts": [{"text": "Let me think...", "thought": true}], "role": "model"}, "index": 0}]}
|
||||
|
||||
data: {"candidates": [{"content": {"parts": [{"text": "The answer is"}], "role": "model"}, "index": 0}]}
|
||||
|
||||
data: {"candidates": [{"content": {"parts": [{"text": " 42."}], "role": "model"}, "finishReason": "STOP", "index": 0}]}
|
||||
|
||||
""";
|
||||
|
||||
this._messageHandlerStub.ResponseToReturn.Content = new StringContent(streamingResponse);
|
||||
var client = this.CreateChatCompletionClient();
|
||||
var chatHistory = CreateSampleChatHistory();
|
||||
|
||||
// Act
|
||||
var messages = await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, messages.Count);
|
||||
|
||||
// First message should contain thought
|
||||
var firstMessage = messages[0];
|
||||
Assert.True(string.IsNullOrEmpty(firstMessage.Content));
|
||||
Assert.Single(firstMessage.Items);
|
||||
#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
var thoughtItem = firstMessage.Items.OfType<StreamingReasoningContent>().Single();
|
||||
#pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
Assert.Equal("Let me think...", thoughtItem.InnerContent);
|
||||
|
||||
// Second and third messages contain regular text
|
||||
var secondMessage = messages[1];
|
||||
Assert.Equal("The answer is", secondMessage.Content);
|
||||
|
||||
var thirdMessage = messages[2];
|
||||
Assert.Equal(" 42.", thirdMessage.Content);
|
||||
}
|
||||
|
||||
private static ChatHistory CreateSampleChatHistory()
|
||||
{
|
||||
var chatHistory = new ChatHistory();
|
||||
|
||||
+63
@@ -268,4 +268,67 @@ public sealed class GeminiPromptExecutionSettingsTests
|
||||
Assert.Throws<NotSupportedException>(() => executionSettings.SafetySettings!.Add(new GeminiSafetySetting(GeminiSafetyCategory.Toxicity, GeminiSafetyThreshold.Unspecified)));
|
||||
Assert.Throws<InvalidOperationException>(() => executionSettings.ThinkingConfig = new GeminiThinkingConfig { ThinkingBudget = 1 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItCreatesThinkingConfigWithIncludeThoughts()
|
||||
{
|
||||
// Arrange & Act
|
||||
var thinkingConfig = new GeminiThinkingConfig
|
||||
{
|
||||
ThinkingBudget = 2000,
|
||||
IncludeThoughts = true
|
||||
};
|
||||
|
||||
var executionSettings = new GeminiPromptExecutionSettings
|
||||
{
|
||||
ThinkingConfig = thinkingConfig
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(executionSettings.ThinkingConfig);
|
||||
Assert.Equal(2000, executionSettings.ThinkingConfig.ThinkingBudget);
|
||||
Assert.True(executionSettings.ThinkingConfig.IncludeThoughts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItSerializesThinkingConfigWithIncludeThoughts()
|
||||
{
|
||||
// Arrange
|
||||
var executionSettings = new GeminiPromptExecutionSettings
|
||||
{
|
||||
ThinkingConfig = new GeminiThinkingConfig
|
||||
{
|
||||
ThinkingBudget = 1500,
|
||||
IncludeThoughts = true
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(executionSettings);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("thinking_budget", json);
|
||||
Assert.Contains("1500", json);
|
||||
Assert.Contains("include_thoughts", json);
|
||||
Assert.Contains("true", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItClonesThinkingConfigWithIncludeThoughts()
|
||||
{
|
||||
// Arrange
|
||||
var original = new GeminiThinkingConfig
|
||||
{
|
||||
ThinkingBudget = 3000,
|
||||
IncludeThoughts = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var cloned = original.Clone();
|
||||
|
||||
// Assert
|
||||
Assert.NotSame(original, cloned);
|
||||
Assert.Equal(original.ThinkingBudget, cloned.ThinkingBudget);
|
||||
Assert.Equal(original.IncludeThoughts, cloned.IncludeThoughts);
|
||||
}
|
||||
}
|
||||
|
||||
+69
-14
@@ -748,20 +748,53 @@ internal sealed class GeminiChatCompletionClient : ClientBase
|
||||
|
||||
private GeminiChatMessageContent GetChatMessageContentFromCandidate(GeminiResponse geminiResponse, GeminiResponseCandidate candidate)
|
||||
{
|
||||
// Join text parts
|
||||
string text = string.Concat(candidate.Content?.Parts?.Select(part => part.Text) ?? []);
|
||||
var items = new List<KernelContent>();
|
||||
|
||||
// Process parts to separate regular text from thinking content
|
||||
var regularTextParts = new List<string>();
|
||||
|
||||
if (candidate.Content?.Parts != null)
|
||||
{
|
||||
foreach (var part in candidate.Content.Parts)
|
||||
{
|
||||
if (part.Thought == true && !string.IsNullOrEmpty(part.Text))
|
||||
{
|
||||
// This is thinking content
|
||||
#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
items.Add(new ReasoningContent(part.Text));
|
||||
#pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(part.Text))
|
||||
{
|
||||
// This is regular text content
|
||||
regularTextParts.Add(part.Text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regular text goes into message.Content, not as a separate item
|
||||
var regularText = string.Concat(regularTextParts);
|
||||
|
||||
// Gemini sometimes returns function calls with text parts, so collect them
|
||||
var toolCalls = candidate.Content?.Parts?
|
||||
.Select(part => part.FunctionCall!)
|
||||
.Where(toolCall => toolCall is not null).ToArray();
|
||||
|
||||
return new GeminiChatMessageContent(
|
||||
// Pass null if there's no regular (non-thinking) text to avoid creating an empty TextContent item
|
||||
var chatMessage = new GeminiChatMessageContent(
|
||||
role: candidate.Content?.Role ?? AuthorRole.Assistant,
|
||||
content: text,
|
||||
content: string.IsNullOrEmpty(regularText) ? null : regularText,
|
||||
modelId: this._modelId,
|
||||
functionsToolCalls: toolCalls,
|
||||
metadata: GetResponseMetadata(geminiResponse, candidate));
|
||||
|
||||
// Add items to the message
|
||||
foreach (var item in items)
|
||||
{
|
||||
chatMessage.Items.Add(item);
|
||||
}
|
||||
|
||||
return chatMessage;
|
||||
}
|
||||
|
||||
private static GeminiRequest CreateRequest(
|
||||
@@ -776,9 +809,11 @@ internal sealed class GeminiChatCompletionClient : ClientBase
|
||||
|
||||
private GeminiStreamingChatMessageContent GetStreamingChatContentFromChatContent(GeminiChatMessageContent message)
|
||||
{
|
||||
GeminiStreamingChatMessageContent streamingMessage;
|
||||
|
||||
if (message.CalledToolResult is not null)
|
||||
{
|
||||
return new GeminiStreamingChatMessageContent(
|
||||
streamingMessage = new GeminiStreamingChatMessageContent(
|
||||
role: message.Role,
|
||||
content: message.Content,
|
||||
modelId: this._modelId,
|
||||
@@ -786,10 +821,9 @@ internal sealed class GeminiChatCompletionClient : ClientBase
|
||||
metadata: message.Metadata,
|
||||
choiceIndex: message.Metadata?.Index ?? 0);
|
||||
}
|
||||
|
||||
if (message.ToolCalls is not null)
|
||||
else if (message.ToolCalls is not null)
|
||||
{
|
||||
return new GeminiStreamingChatMessageContent(
|
||||
streamingMessage = new GeminiStreamingChatMessageContent(
|
||||
role: message.Role,
|
||||
content: message.Content,
|
||||
modelId: this._modelId,
|
||||
@@ -797,13 +831,34 @@ internal sealed class GeminiChatCompletionClient : ClientBase
|
||||
metadata: message.Metadata,
|
||||
choiceIndex: message.Metadata?.Index ?? 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
streamingMessage = new GeminiStreamingChatMessageContent(
|
||||
role: message.Role,
|
||||
content: message.Content,
|
||||
modelId: this._modelId,
|
||||
choiceIndex: message.Metadata?.Index ?? 0,
|
||||
metadata: message.Metadata);
|
||||
}
|
||||
|
||||
return new GeminiStreamingChatMessageContent(
|
||||
role: message.Role,
|
||||
content: message.Content,
|
||||
modelId: this._modelId,
|
||||
choiceIndex: message.Metadata?.Index ?? 0,
|
||||
metadata: message.Metadata);
|
||||
// Copy ReasoningContent items to streaming message, converting to StreamingReasoningContent
|
||||
foreach (var item in message.Items)
|
||||
{
|
||||
#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
if (item is ReasoningContent reasoningContent)
|
||||
{
|
||||
var streamingReasoning = new StreamingReasoningContent(reasoningContent.Text)
|
||||
{
|
||||
InnerContent = reasoningContent.Text
|
||||
};
|
||||
streamingMessage.Items.Add(streamingReasoning);
|
||||
}
|
||||
// Note: Other item types like TextContent are not copied since the main content
|
||||
// is already in streamingMessage.Content
|
||||
#pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
}
|
||||
|
||||
return streamingMessage;
|
||||
}
|
||||
|
||||
private static void ValidateAutoInvoke(bool autoInvoke, int resultsPerPrompt)
|
||||
|
||||
@@ -47,6 +47,13 @@ internal sealed class GeminiPart : IJsonOnDeserialized
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public FunctionResponsePart? FunctionResponse { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this part contains thinking content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("thought")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? Thought { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether only one property of the GeminiPart instance is not null.
|
||||
/// Returns true if only one property among Text, InlineData, FileData, FunctionCall, and FunctionResponse is not null,
|
||||
|
||||
@@ -501,6 +501,7 @@ internal sealed class GeminiRequest
|
||||
request.Configuration.ThinkingConfig = new GeminiRequestThinkingConfig
|
||||
{
|
||||
ThinkingBudget = executionSettings.ThinkingConfig.ThinkingBudget,
|
||||
IncludeThoughts = executionSettings.ThinkingConfig.IncludeThoughts,
|
||||
ThinkingLevel = executionSettings.ThinkingConfig.ThinkingLevel
|
||||
};
|
||||
}
|
||||
@@ -555,6 +556,10 @@ internal sealed class GeminiRequest
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? ThinkingBudget { get; set; }
|
||||
|
||||
[JsonPropertyName("includeThoughts")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? IncludeThoughts { get; set; }
|
||||
|
||||
[JsonPropertyName("thinkingLevel")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ThinkingLevel { get; set; }
|
||||
|
||||
@@ -31,6 +31,13 @@ public class GeminiThinkingConfig
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ThinkingLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include the thinking content in the response.
|
||||
/// </summary>
|
||||
[JsonPropertyName("include_thoughts")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? IncludeThoughts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Clones this instance.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user