Skip to content

Commit f6d976c

Browse files
Merge origin/main and resolve AIChatService conflict
Co-authored-by: BenjaminMichaelis <22186029+BenjaminMichaelis@users.noreply.github.com>
2 parents 12b0886 + bc6948e commit f6d976c

83 files changed

Lines changed: 5372 additions & 399 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.editorconfig

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,6 @@ csharp_style_prefer_top_level_statements = true:silent
155155
csharp_style_expression_bodied_lambdas = true:silent
156156
csharp_style_expression_bodied_local_functions = false:silent
157157

158-
# CA1848: Use the LoggerMessage delegates
159-
dotnet_diagnostic.CA1848.severity = suggestion
160158
# Test files - allow underscore-separated test method names (CA1707)
161159
[{EssentialCSharp.Web.Tests,EssentialCSharp.Chat.Tests}/**]
162160
dotnet_diagnostic.CA1707.severity = none

.github/workflows/Build-Test-And-Deploy.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ jobs:
7171
push: false
7272
tags: temp-pr-validation
7373
file: ./EssentialCSharp.Web/Dockerfile
74+
context: .
75+
build-args: ACCESS_TO_NUGET_FEED=false
7476

7577
- name: Build Container Image
7678
if: github.event_name != 'pull_request_target' && github.event_name != 'pull_request'
@@ -80,7 +82,7 @@ jobs:
8082
file: ./EssentialCSharp.Web/Dockerfile
8183
context: .
8284
secrets: |
83-
"nuget_auth_token=${{ secrets.AZURE_DEVOPS_PAT }}"
85+
"nuget_pat=${{ secrets.AZURE_DEVOPS_PAT }}"
8486
outputs: type=docker,dest=${{ github.workspace }}/essentialcsharpwebimage.tar
8587
cache-from: type=gha
8688
cache-to: type=gha,mode=max
@@ -172,7 +174,7 @@ jobs:
172174
AIOptions__Endpoint=secretref:ai-endpoint AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \
173175
AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \
174176
TryDotNet__Origin=$TRYDOTNET_ORIGIN DataProtection__AzureKeyVaultKeyUri=$KEYVAULTURI/keys/dataprotection \
175-
HCaptcha__ExpectedHostname=staging.essentialcsharp.com
177+
HCaptcha__ExpectedHostname=essentialcsharp.com
176178
- name: Logout of Azure CLI
177179
if: always()
178180
uses: azure/CLI@v3

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ TestResults/
1010
~$*.do[ct]x
1111
~*.tmp
1212
~$*.dotm
13+
.playwright-mcp/
1314

1415
# Files to keep (primarily book content)
1516
!.gitignore

Directory.Packages.props

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,12 @@
22
<PropertyGroup>
33
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
44
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
5-
<ToolingPackagesVersion>1.1.1.18996</ToolingPackagesVersion>
5+
<ToolingPackagesVersion>1.1.1.19025</ToolingPackagesVersion>
66
<AccessToNugetFeed Condition="'$(AccessToNugetFeed)' == ''">false</AccessToNugetFeed>
7-
<RestoreSources>
8-
https://api.nuget.org/v3/index.json;
9-
</RestoreSources>
10-
<RestoreSources Condition="$(AccessToNugetFeed)">
11-
$(RestoreSources);
12-
https://pkgs.dev.azure.com/intelliTect/_packaging/EssentialCSharp/nuget/v3/index.json;
13-
</RestoreSources>
7+
<!-- Disable NuGet vulnerability audit when the private feed is unavailable (e.g. CI without credentials).
8+
NuGet audit queries all sources in nuget.config regardless of RestoreSources, causing NU1900 which is
9+
escalated to an error by TreatWarningsAsErrors. -->
10+
<NuGetAudit Condition="'$(AccessToNugetFeed)' != 'true'">false</NuGetAudit>
1411
</PropertyGroup>
1512
<PropertyGroup>
1613
<SemanticKernelVersion>1.72.0</SemanticKernelVersion>
@@ -19,27 +16,27 @@
1916
<PackageVersion Include="ContentFeedNuget" Version="$(ToolingPackagesVersion)" />
2017
</ItemGroup>
2118
<ItemGroup>
22-
<PackageVersion Include="Azure.Extensions.AspNetCore.DataProtection.Keys" Version="1.6.1" />
19+
<PackageVersion Include="Azure.Extensions.AspNetCore.DataProtection.Keys" Version="1.6.2" />
2320
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
2421
<PackageVersion Include="AspNet.Security.OAuth.GitHub" Version="10.0.0" />
25-
<PackageVersion Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.0" />
22+
<PackageVersion Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.1" />
2623
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
27-
<PackageVersion Include="Azure.Monitor.OpenTelemetry.AspNetCore" Version="1.4.0" />
28-
<PackageVersion Include="TUnit" Version="1.39.0" />
24+
<PackageVersion Include="Azure.Monitor.OpenTelemetry.AspNetCore" Version="1.5.0" />
25+
<PackageVersion Include="TUnit" Version="1.40.5" />
2926
<PackageVersion Include="EssentialCSharp.Shared.Models" Version="$(ToolingPackagesVersion)" />
3027
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
3128
<PackageVersion Include="IntelliTect.Multitool" Version="2.0.0" />
3229
<PackageVersion Include="Mailjet.Api" Version="4.0.0" />
3330
<PackageVersion Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="10.0.7" />
3431
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
35-
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.6" />
32+
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.7" />
3633
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.7" />
37-
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.6" />
34+
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
3835
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.0.0" />
3936
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
40-
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.6" />
37+
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
4138
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
42-
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.6" />
39+
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7" />
4340
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.5.0" />
4441
<PackageVersion Include="Microsoft.SemanticKernel" Version="$(SemanticKernelVersion)" />
4542
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.PgVector" Version="$(SemanticKernelVersion)-preview" />
@@ -50,8 +47,9 @@
5047
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
5148
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
5249
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.0.1-preview.1.25571.5" />
53-
<PackageVersion Include="ModelContextProtocol" Version="0.3.0-preview.4" />
54-
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="0.3.0-preview.4" />
50+
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
51+
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
52+
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
5553
<PackageVersion Include="Moq" Version="4.20.72" />
5654
<PackageVersion Include="Moq.AutoMock" Version="4.0.2" />
5755
<PackageVersion Include="System.CommandLine" Version="2.0.7" />

EssentialCSharp.Chat.Shared/Services/AIChatService.cs

Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
4646
string prompt,
4747
string? systemPrompt = null,
4848
string? previousResponseId = null,
49-
IMcpClient? mcpClient = null,
49+
McpClient? mcpClient = null,
5050
#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.
5151
IEnumerable<ResponseTool>? tools = null,
5252
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
@@ -56,7 +56,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
5656
{
5757
var responseOptions = await CreateResponseOptionsAsync(systemPrompt, previousResponseId, tools, reasoningEffortLevel, mcpClient: mcpClient, cancellationToken: cancellationToken);
5858
var enrichedPrompt = await EnrichPromptWithContext(prompt, enableContextualSearch, cancellationToken);
59-
return await GetChatCompletionCore(enrichedPrompt, responseOptions, cancellationToken);
59+
return await GetChatCompletionCore(enrichedPrompt, responseOptions, mcpClient, cancellationToken);
6060
}
6161

6262
/// <summary>
@@ -74,7 +74,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
7474
string prompt,
7575
string? systemPrompt = null,
7676
string? previousResponseId = null,
77-
IMcpClient? mcpClient = null,
77+
McpClient? mcpClient = null,
7878
#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.
7979
IEnumerable<ResponseTool>? tools = null,
8080
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
@@ -94,7 +94,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
9494
options: responseOptions,
9595
cancellationToken: cancellationToken);
9696

97-
await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, cancellationToken))
97+
await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, toolCallDepth: 0, cancellationToken))
9898
{
9999
yield return result;
100100
}
@@ -138,7 +138,8 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
138138
IAsyncEnumerable<StreamingResponseUpdate> streamingUpdates,
139139
ResponseCreationOptions responseOptions,
140140
#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.
141-
IMcpClient? mcpClient,
141+
McpClient? mcpClient,
142+
int toolCallDepth = 0,
142143
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
143144
{
144145
await foreach (var update in streamingUpdates.WithCancellation(cancellationToken))
@@ -155,8 +156,11 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
155156
// Check if this is a function call that needs to be executed
156157
if (itemDone.Item is FunctionCallResponseItem functionCallItem && mcpClient != null)
157158
{
159+
if (toolCallDepth >= 10)
160+
throw new InvalidOperationException("Maximum tool call depth exceeded.");
161+
158162
// Execute the function call and stream its response
159-
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, cancellationToken))
163+
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, toolCallDepth + 1, cancellationToken))
160164
{
161165
if (functionResult.responseId != null)
162166
{
@@ -186,7 +190,8 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
186190
FunctionCallResponseItem functionCallItem,
187191
ResponseCreationOptions responseOptions,
188192
#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.
189-
IMcpClient mcpClient,
193+
McpClient mcpClient,
194+
int toolCallDepth,
190195
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
191196
{
192197
// A dictionary of arguments to pass to the tool. Each key represents a parameter name, and its associated value represents the argument value.
@@ -229,7 +234,7 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
229234
var inputItems = new List<ResponseItem>
230235
{
231236
functionCallItem, // The original function call
232-
new FunctionCallOutputResponseItem(functionCallItem.CallId, string.Join("", toolResult.Content.Where(x => x.Type == "text").OfType<TextContentBlock>().Select(x => x.Text)))
237+
new FunctionCallOutputResponseItem(functionCallItem.CallId, McpToolResultFormatter.GetModelInput(toolResult))
233238
};
234239
#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.
235240

@@ -239,7 +244,7 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
239244
responseOptions,
240245
cancellationToken);
241246

242-
await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, cancellationToken))
247+
await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, toolCallDepth, cancellationToken))
243248
{
244249
yield return result;
245250
}
@@ -254,7 +259,7 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
254259
string? previousResponseId = null,
255260
IEnumerable<ResponseTool>? tools = null,
256261
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
257-
IMcpClient? mcpClient = null,
262+
McpClient? mcpClient = null,
258263
CancellationToken cancellationToken = default
259264
)
260265
{
@@ -286,7 +291,8 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
286291

287292
if (mcpClient is not null)
288293
{
289-
await foreach (McpClientTool tool in mcpClient.EnumerateToolsAsync(cancellationToken: cancellationToken))
294+
var mcpTools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken);
295+
foreach (McpClientTool tool in mcpTools)
290296
{
291297
#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.
292298
options.Tools.Add(ResponseTool.CreateFunctionTool(tool.Name, functionDescription: tool.Description, strictModeEnabled: true, functionParameters: BinaryData.FromString(tool.JsonSchema.GetRawText())));
@@ -316,35 +322,74 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
316322
#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.
317323
ResponseCreationOptions responseOptions,
318324
#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.
325+
McpClient? mcpClient = null,
319326
CancellationToken cancellationToken = default)
320327
{
321328
// Create the response using the Responses API
322329
#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.
323330
List<ResponseItem> responseItems = [ResponseItem.CreateUserMessageItem(prompt)];
324331
#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.
325332

326-
var response = await _ResponseClient.CreateResponseAsync(
327-
responseItems,
328-
options: responseOptions,
329-
cancellationToken: cancellationToken);
333+
const int MaxToolCallIterations = 10;
334+
for (int iteration = 0; iteration < MaxToolCallIterations; iteration++)
335+
{
336+
var response = await _ResponseClient.CreateResponseAsync(
337+
responseItems,
338+
options: responseOptions,
339+
cancellationToken: cancellationToken);
330340

331-
// Extract the message content and response ID
332-
string responseText = string.Empty;
333-
string responseId = response.Value.Id;
341+
string responseId = response.Value.Id;
334342

335343
#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.
336-
var assistantMessage = response.Value.OutputItems
337-
.OfType<MessageResponseItem>()
338-
.FirstOrDefault(m => m.Role == MessageRole.Assistant &&
339-
!string.IsNullOrEmpty(m.Content?.FirstOrDefault()?.Text));
344+
var functionCalls = response.Value.OutputItems.OfType<FunctionCallResponseItem>().ToList();
340345

341-
if (assistantMessage is not null)
342-
{
343-
responseText = assistantMessage.Content?.FirstOrDefault()?.Text ?? string.Empty;
344-
}
346+
if (functionCalls.Count > 0 && mcpClient != null)
347+
{
348+
foreach (var functionCallItem in functionCalls)
349+
{
350+
var jsonResponse = functionCallItem.FunctionArguments.ToString();
351+
var jsonArguments = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonResponse) ?? new Dictionary<string, object?>();
352+
353+
Dictionary<string, object?> arguments = [];
354+
foreach (var kvp in jsonArguments)
355+
{
356+
arguments[kvp.Key] = kvp.Value is System.Text.Json.JsonElement jsonElement
357+
? jsonElement.ValueKind switch
358+
{
359+
System.Text.Json.JsonValueKind.String => jsonElement.GetString(),
360+
System.Text.Json.JsonValueKind.Number => jsonElement.GetDecimal(),
361+
System.Text.Json.JsonValueKind.True => true,
362+
System.Text.Json.JsonValueKind.False => false,
363+
System.Text.Json.JsonValueKind.Null => null,
364+
_ => (object?)jsonElement.ToString()
365+
}
366+
: kvp.Value;
367+
}
368+
369+
var toolResult = await mcpClient.CallToolAsync(
370+
functionCallItem.FunctionName,
371+
arguments: arguments,
372+
cancellationToken: cancellationToken);
373+
374+
responseItems.Add(functionCallItem);
375+
responseItems.Add(new FunctionCallOutputResponseItem(
376+
functionCallItem.CallId,
377+
McpToolResultFormatter.GetModelInput(toolResult)));
378+
}
379+
continue;
380+
}
381+
382+
var assistantMessage = response.Value.OutputItems
383+
.OfType<MessageResponseItem>()
384+
.FirstOrDefault(m => m.Role == MessageRole.Assistant &&
385+
!string.IsNullOrEmpty(m.Content?.FirstOrDefault()?.Text));
386+
387+
string responseText = assistantMessage?.Content?.FirstOrDefault()?.Text ?? string.Empty;
345388
#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.
389+
return (responseText, responseId);
390+
}
346391

347-
return (responseText, responseId);
392+
throw new InvalidOperationException("Maximum tool call iterations exceeded.");
348393
}
349394

350395
// TODO: Look into using UserSecurityContext (https://learn.microsoft.com/en-us/azure/defender-for-cloud/gain-end-user-context-ai)

0 commit comments

Comments
 (0)