Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.0.1-preview.1.25571.5" />
<PackageVersion Include="ModelContextProtocol" Version="0.3.0-preview.4" />
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="0.3.0-preview.4" />
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Moq.AutoMock" Version="4.0.2" />
<PackageVersion Include="System.CommandLine" Version="2.0.7" />
Expand Down
99 changes: 71 additions & 28 deletions EssentialCSharp.Chat.Shared/Services/AIChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
string prompt,
string? systemPrompt = null,
string? previousResponseId = null,
IMcpClient? mcpClient = null,
McpClient? mcpClient = null,
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
IEnumerable<ResponseTool>? tools = null,
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
Expand All @@ -56,7 +56,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
{
var responseOptions = await CreateResponseOptionsAsync(previousResponseId, tools, reasoningEffortLevel, mcpClient: mcpClient, cancellationToken: cancellationToken);
var enrichedPrompt = await EnrichPromptWithContext(prompt, enableContextualSearch, cancellationToken);
return await GetChatCompletionCore(enrichedPrompt, responseOptions, systemPrompt, cancellationToken);
return await GetChatCompletionCore(enrichedPrompt, responseOptions, systemPrompt, mcpClient, cancellationToken);
}

/// <summary>
Expand All @@ -74,7 +74,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
string prompt,
string? systemPrompt = null,
string? previousResponseId = null,
IMcpClient? mcpClient = null,
McpClient? mcpClient = null,
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
IEnumerable<ResponseTool>? tools = null,
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
Expand All @@ -99,7 +99,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
options: responseOptions,
cancellationToken: cancellationToken);

await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, cancellationToken))
await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, toolCallDepth: 0, cancellationToken))
{
yield return result;
}
Expand Down Expand Up @@ -143,7 +143,8 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
IAsyncEnumerable<StreamingResponseUpdate> streamingUpdates,
ResponseCreationOptions responseOptions,
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
IMcpClient? mcpClient,
McpClient? mcpClient,
int toolCallDepth = 0,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var update in streamingUpdates.WithCancellation(cancellationToken))
Expand All @@ -160,8 +161,11 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
// Check if this is a function call that needs to be executed
if (itemDone.Item is FunctionCallResponseItem functionCallItem && mcpClient != null)
{
if (toolCallDepth >= 10)
throw new InvalidOperationException("Maximum tool call depth exceeded.");

// Execute the function call and stream its response
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, cancellationToken))
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, toolCallDepth + 1, cancellationToken))
{
if (functionResult.responseId != null)
{
Expand Down Expand Up @@ -191,7 +195,8 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
FunctionCallResponseItem functionCallItem,
ResponseCreationOptions responseOptions,
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
IMcpClient mcpClient,
McpClient mcpClient,
int toolCallDepth,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// A dictionary of arguments to pass to the tool. Each key represents a parameter name, and its associated value represents the argument value.
Expand Down Expand Up @@ -244,7 +249,7 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
responseOptions,
cancellationToken);

await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, cancellationToken))
await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, toolCallDepth, cancellationToken))
{
yield return result;
}
Expand All @@ -258,7 +263,7 @@ private static async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
string? previousResponseId = null,
IEnumerable<ResponseTool>? tools = null,
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
IMcpClient? mcpClient = null,
McpClient? mcpClient = null,
CancellationToken cancellationToken = default
)
{
Expand All @@ -282,7 +287,8 @@ private static async Task<ResponseCreationOptions> CreateResponseOptionsAsync(

if (mcpClient is not null)
{
await foreach (McpClientTool tool in mcpClient.EnumerateToolsAsync(cancellationToken: cancellationToken))
var mcpTools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken);
foreach (McpClientTool tool in mcpTools)
{
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
options.Tools.Add(ResponseTool.CreateFunctionTool(tool.Name, functionDescription: tool.Description, strictModeEnabled: true, functionParameters: BinaryData.FromString(tool.JsonSchema.GetRawText())));
Expand Down Expand Up @@ -313,41 +319,78 @@ private static async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
ResponseCreationOptions responseOptions,
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
string? systemPrompt = null,
McpClient? mcpClient = null,
CancellationToken cancellationToken = default)
{
// Construct the user input with system context if provided
var systemContext = !string.IsNullOrWhiteSpace(systemPrompt) ? systemPrompt : _Options.SystemPrompt;

// Create the streaming response using the Responses API
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
List<ResponseItem> responseItems = systemContext is not null
? [ResponseItem.CreateSystemMessageItem(systemContext), ResponseItem.CreateUserMessageItem(prompt)]
: [ResponseItem.CreateUserMessageItem(prompt)];
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

// Create the response using the Responses API
var response = await _ResponseClient.CreateResponseAsync(
responseItems,
options: responseOptions,
cancellationToken: cancellationToken);
const int MaxToolCallIterations = 10;
for (int iteration = 0; iteration < MaxToolCallIterations; iteration++)
{
var response = await _ResponseClient.CreateResponseAsync(
responseItems,
options: responseOptions,
cancellationToken: cancellationToken);

// Extract the message content and response ID
string responseText = string.Empty;
string responseId = response.Value.Id;
string responseId = response.Value.Id;

#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var assistantMessage = response.Value.OutputItems
.OfType<MessageResponseItem>()
.FirstOrDefault(m => m.Role == MessageRole.Assistant &&
!string.IsNullOrEmpty(m.Content?.FirstOrDefault()?.Text));
var functionCalls = response.Value.OutputItems.OfType<FunctionCallResponseItem>().ToList();

if (assistantMessage is not null)
{
responseText = assistantMessage.Content?.FirstOrDefault()?.Text ?? string.Empty;
}
if (functionCalls.Count > 0 && mcpClient != null)
{
foreach (var functionCallItem in functionCalls)
{
var jsonResponse = functionCallItem.FunctionArguments.ToString();
var jsonArguments = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonResponse) ?? new Dictionary<string, object?>();

Dictionary<string, object?> arguments = [];
foreach (var kvp in jsonArguments)
{
arguments[kvp.Key] = kvp.Value is System.Text.Json.JsonElement jsonElement
? jsonElement.ValueKind switch
{
System.Text.Json.JsonValueKind.String => jsonElement.GetString(),
System.Text.Json.JsonValueKind.Number => jsonElement.GetDecimal(),
System.Text.Json.JsonValueKind.True => true,
System.Text.Json.JsonValueKind.False => false,
System.Text.Json.JsonValueKind.Null => null,
_ => (object?)jsonElement.ToString()
}
: kvp.Value;
}

var toolResult = await mcpClient.CallToolAsync(
functionCallItem.FunctionName,
arguments: arguments,
cancellationToken: cancellationToken);

responseItems.Add(functionCallItem);
responseItems.Add(new FunctionCallOutputResponseItem(
functionCallItem.CallId,
string.Join("", toolResult.Content.Where(x => x.Type == "text").OfType<TextContentBlock>().Select(x => x.Text))));
}
continue;
}

var assistantMessage = response.Value.OutputItems
.OfType<MessageResponseItem>()
.FirstOrDefault(m => m.Role == MessageRole.Assistant &&
!string.IsNullOrEmpty(m.Content?.FirstOrDefault()?.Text));

string responseText = assistantMessage?.Content?.FirstOrDefault()?.Text ?? string.Empty;
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
return (responseText, responseId);
}

return (responseText, responseId);
throw new InvalidOperationException("Maximum tool call iterations exceeded.");
}

// TODO: Look into using UserSecurityContext (https://learn.microsoft.com/en-us/azure/defender-for-cloud/gain-end-user-context-ai)
Expand Down
8 changes: 6 additions & 2 deletions EssentialCSharp.Chat.Shared/Services/AISearchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ public class AISearchService(
{
// TODO: Implement Hybrid Search functionality, may need to switch db providers to support full text search?

public const int DefaultSearchTop = 5;
public const int MaxSearchTop = 10;

public async Task<IReadOnlyList<VectorSearchResult<BookContentChunk>>> ExecuteVectorSearch(
string query, string? collectionName = null, CancellationToken cancellationToken = default)
string query, string? collectionName = null, int top = DefaultSearchTop, CancellationToken cancellationToken = default)
{
top = Math.Clamp(top, 1, MaxSearchTop);
collectionName ??= EmbeddingService.CollectionName;

VectorStoreCollection<string, BookContentChunk> collection = vectorStore.GetCollection<string, BookContentChunk>(collectionName);
Expand All @@ -32,7 +36,7 @@ public async Task<IReadOnlyList<VectorSearchResult<BookContentChunk>>> ExecuteVe
try
{
var results = new List<VectorSearchResult<BookContentChunk>>();
await foreach (var result in collection.SearchAsync(searchVector, options: vectorSearchOptions, top: 3, cancellationToken: cancellationToken))
await foreach (var result in collection.SearchAsync(searchVector, options: vectorSearchOptions, top: top, cancellationToken: cancellationToken))
{
results.Add(result);
}
Expand Down
Loading
Loading