Skip to content

Commit 98c51d0

Browse files
committed
Part 2: LibreChat integration and Tool Calling Middleware
Add HTTP request logging and improved error handling - Enable HTTP request body logging to debug failed requests - Add exception handling middleware for production and development - Configure logging levels for ASP.NET diagnostics and HTTP logging - Log request method, path, query, status, and duration Map OpenAI chat completions endpoints for all registered agents Add /v1/chat/completions routing middleware Route requests to /{model}/v1/chat/completions based on model field in request body. Defaults to Knowledge agent if model not specified. Fixup naming. Add ToolCallFilterAgent to prevent downstream tool call misinterpretation - Add ToolCallFilterAgent as a DelegatingAIAgent that filters out FunctionCallContent and FunctionResultContent from responses - Add ToolCallFilteringChatClient for IChatClient-level filtering - Wrap KnowledgeSearchAgent with ToolCallFilterAgent in AgentFactory - Ensures downstream clients don't see/attempt to execute upstream tool calls Add KnowledgeTitleAgent for LibreChat title generation Restore full namespaces in logging and suppress EF Core debug logs Remove extended HTTP request logging Simplify title agent error
1 parent 7aed5c0 commit 98c51d0

10 files changed

Lines changed: 355 additions & 43 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ A hands-on companion repository for the AI Agents in .NET blog series at [svnsch
1111
| `main` | Latest | Merged version of all branches. | [![Open in Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/svnscha/knowledge/tree/main) |
1212
| `part/00-repository-setup` | [Repository & Hello World Agent](https://svnscha.de/posts/ai-agents-dotnet-intro/) | Project setup, DevUI, your first conversational agent | [![Open in Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/svnscha/knowledge/tree/part/00-repository-setup) |
1313
| `part/01-agentic-rag` | [AI Agents in .NET: Building Agentic RAG](https://svnscha.de/posts/ai-agents-dotnet-part-1/) | PostgreSQL storage, Tool calling, Message Persistence, Embeddings | [![Open in Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/svnscha/knowledge/tree/part/01-agentic-rag) |
14+
| `part/02-connect-librechat` | [AI Agents in .NET: Beyond DevUI - LibreChat Integration](https://svnscha.de/posts/ai-agents-dotnet-part-2/) | LibreChat, Tool Call Middleware, Hosting as OpenAI Compatible API | [![Open in Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/svnscha/knowledge/tree/part/02-connect-librechat) |
15+
1416

1517

1618
> *New branches added as the series progresses. Star the repo to stay updated!*

src/Knowledge.Shared/Agents/AgentFactory.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,21 +55,33 @@ public static ChatClientAgent CreateKnowledgeAgent(IChatClient chatClient, IServ
5555
5656
Remember: You decide when search helps. Don't search reflexively for every question.";
5757

58-
public static ChatClientAgent CreateKnowledgeSearchAgent(IChatClient chatClient, IServiceProvider services, string key)
58+
public static AIAgent CreateKnowledgeSearchAgent(IChatClient chatClient, IServiceProvider services, string key)
5959
{
6060
var searchAgent = services.GetRequiredService<KnowledgeSearchAgent>();
6161

62-
return chatClient.CreateAIAgent(new ChatClientAgentOptions
62+
var agent = chatClient.CreateAIAgent(new ChatClientAgentOptions
6363
{
6464
Id = key,
6565
Name = key,
6666
ChatOptions = new ChatOptions
6767
{
6868
ConversationId = "global",
6969
Instructions = KnowledgeSearchSystemPrompt,
70-
Tools = [AIFunctionFactory.Create(searchAgent.SearchConversationHistoryAsync)],
70+
Tools = [AIFunctionFactory.Create(searchAgent.SearchConversationHistoryAsync, "SearchConversationHistory")],
7171
ToolMode = ChatToolMode.Auto
7272
}
7373
});
74+
75+
// Wrap with filter to prevent downstream consumers from seeing tool calls they can't execute
76+
return new ToolCallFilterAgent(agent);
77+
}
78+
79+
/// <summary>
80+
/// Creates the Knowledge Title agent for conversation title generation.
81+
/// Simple assistant with no tools - designed for LibreChat's title generation.
82+
/// </summary>
83+
public static ChatClientAgent CreateKnowledgeTitleAgent(IChatClient chatClient, IServiceProvider services, string key)
84+
{
85+
return KnowledgeTitleAgent.Create(chatClient, services, key);
7486
}
7587
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using Knowledge.Shared.Data;
2+
using Knowledge.Shared.Storage;
3+
using Microsoft.Agents.AI;
4+
using Microsoft.EntityFrameworkCore;
5+
using Microsoft.Extensions.AI;
6+
using Microsoft.Extensions.DependencyInjection;
7+
8+
namespace Knowledge.Shared.Agents;
9+
10+
/// <summary>
11+
/// Simple agent for generating conversation titles.
12+
/// No tools, no embeddings - just a basic helpful assistant.
13+
/// Designed for use with LibreChat's title generation feature.
14+
/// </summary>
15+
public static class KnowledgeTitleAgent
16+
{
17+
private const string TitleSystemPrompt = @"You are a helpful assistant that generates concise, descriptive titles for conversations.
18+
When given conversation content, create a brief title (3-7 words) that captures the main topic or purpose.
19+
Be specific and informative. Avoid generic titles like 'Chat' or 'Conversation'.";
20+
21+
/// <summary>
22+
/// Creates a title generation agent.
23+
/// </summary>
24+
public static ChatClientAgent Create(IChatClient chatClient, IServiceProvider services, string key)
25+
{
26+
return chatClient.CreateAIAgent(new ChatClientAgentOptions
27+
{
28+
Id = key,
29+
Name = key,
30+
ChatOptions = new ChatOptions
31+
{
32+
ConversationId = "global",
33+
Instructions = TitleSystemPrompt,
34+
}
35+
});
36+
}
37+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System.Runtime.CompilerServices;
2+
using Microsoft.Agents.AI;
3+
using Microsoft.Extensions.AI;
4+
5+
namespace Knowledge.Shared.Agents;
6+
7+
/// <summary>
8+
/// A delegating agent that filters out tool call content from responses.
9+
/// This prevents downstream consumers from seeing FunctionCallContent and FunctionResultContent
10+
/// that they cannot execute.
11+
/// </summary>
12+
public sealed class ToolCallFilterAgent : DelegatingAIAgent
13+
{
14+
public ToolCallFilterAgent(AIAgent innerAgent) : base(innerAgent) { }
15+
16+
public override async Task<AgentRunResponse> RunAsync(
17+
IEnumerable<ChatMessage> messages,
18+
AgentThread? thread,
19+
AgentRunOptions? options,
20+
CancellationToken cancellationToken)
21+
{
22+
var response = await InnerAgent.RunAsync(messages, thread, options, cancellationToken);
23+
response.Messages = FilterToolCalls(response.Messages);
24+
return response;
25+
}
26+
27+
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
28+
IEnumerable<ChatMessage> messages,
29+
AgentThread? thread,
30+
AgentRunOptions? options,
31+
[EnumeratorCancellation] CancellationToken cancellationToken)
32+
{
33+
await foreach (var update in InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken))
34+
{
35+
yield return FilterToolCalls(update);
36+
}
37+
}
38+
39+
private static IList<ChatMessage> FilterToolCalls(IEnumerable<ChatMessage> messages) =>
40+
messages.Select(m => new ChatMessage(m.Role,
41+
m.Contents.Where(c => c is not FunctionCallContent && c is not FunctionResultContent).ToList()
42+
)).ToList();
43+
44+
private static AgentRunResponseUpdate FilterToolCalls(AgentRunResponseUpdate update) =>
45+
new(update.Role, update.Contents
46+
.Where(c => c is not FunctionCallContent && c is not FunctionResultContent).ToList());
47+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
using System.Runtime.CompilerServices;
2+
using Microsoft.Extensions.AI;
3+
using Microsoft.Extensions.Logging;
4+
5+
namespace Knowledge.Shared.Agents;
6+
7+
/// <summary>
8+
/// A delegating chat client that filters out tool call content from responses.
9+
/// This is useful when the upstream model uses tools internally but the downstream
10+
/// consumer doesn't have access to those tools.
11+
/// </summary>
12+
public class ToolCallFilteringChatClient : DelegatingChatClient
13+
{
14+
private readonly ILogger<ToolCallFilteringChatClient>? _logger;
15+
16+
public ToolCallFilteringChatClient(IChatClient innerClient, ILogger<ToolCallFilteringChatClient>? logger = null)
17+
: base(innerClient)
18+
{
19+
_logger = logger;
20+
}
21+
22+
public override async Task<ChatResponse> GetResponseAsync(
23+
IEnumerable<ChatMessage> messages,
24+
ChatOptions? options = null,
25+
CancellationToken cancellationToken = default)
26+
{
27+
var response = await base.GetResponseAsync(messages, options, cancellationToken);
28+
29+
// Filter and transform the response messages
30+
var filteredMessages = new List<ChatMessage>();
31+
foreach (var message in response.Messages)
32+
{
33+
var filtered = FilterMessage(message);
34+
if (filtered != null)
35+
{
36+
filteredMessages.Add(filtered);
37+
}
38+
}
39+
40+
return new ChatResponse(filteredMessages)
41+
{
42+
CreatedAt = response.CreatedAt,
43+
FinishReason = response.FinishReason,
44+
ModelId = response.ModelId,
45+
RawRepresentation = response.RawRepresentation,
46+
ResponseId = response.ResponseId,
47+
Usage = response.Usage
48+
};
49+
}
50+
51+
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
52+
IEnumerable<ChatMessage> messages,
53+
ChatOptions? options = null,
54+
[EnumeratorCancellation] CancellationToken cancellationToken = default)
55+
{
56+
await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken))
57+
{
58+
_logger?.LogDebug("ToolCallFilter: Received update with {ContentCount} contents, Role={Role}, FinishReason={FinishReason}",
59+
update.Contents.Count, update.Role, update.FinishReason);
60+
61+
foreach (var content in update.Contents)
62+
{
63+
_logger?.LogDebug("ToolCallFilter: Content type={Type}", content.GetType().Name);
64+
}
65+
66+
var filtered = FilterUpdate(update);
67+
if (filtered != null)
68+
{
69+
_logger?.LogDebug("ToolCallFilter: Yielding filtered update with {ContentCount} contents", filtered.Contents.Count);
70+
yield return filtered;
71+
}
72+
else
73+
{
74+
_logger?.LogDebug("ToolCallFilter: Skipping update (filtered to null)");
75+
}
76+
}
77+
}
78+
79+
private static ChatMessage? FilterMessage(ChatMessage message)
80+
{
81+
// Skip tool call messages entirely
82+
if (message.Role == ChatRole.Assistant)
83+
{
84+
var hasToolCalls = message.Contents.Any(c => c is FunctionCallContent);
85+
var hasToolResults = message.Contents.Any(c => c is FunctionResultContent);
86+
87+
if (hasToolCalls || hasToolResults)
88+
{
89+
// Filter out tool-related content, keep only non-tool content
90+
var filteredContent = message.Contents
91+
.Where(c => c is not FunctionCallContent and not FunctionResultContent)
92+
.ToList();
93+
94+
if (filteredContent.Count == 0)
95+
{
96+
return null;
97+
}
98+
99+
return new ChatMessage(message.Role, filteredContent);
100+
}
101+
}
102+
103+
// Skip tool role messages entirely
104+
if (message.Role == ChatRole.Tool)
105+
{
106+
return null;
107+
}
108+
109+
return message;
110+
}
111+
112+
private static ChatResponseUpdate? FilterUpdate(ChatResponseUpdate update)
113+
{
114+
// Check if this update contains tool calls or results
115+
var hasToolCalls = update.Contents.Any(c => c is FunctionCallContent);
116+
var hasToolResults = update.Contents.Any(c => c is FunctionResultContent);
117+
118+
if (hasToolCalls || hasToolResults)
119+
{
120+
// Filter out tool-related content, keep only non-tool content
121+
var filteredContents = update.Contents
122+
.Where(c => c is not FunctionCallContent and not FunctionResultContent)
123+
.ToList();
124+
125+
// If there's no non-tool content, skip this update entirely
126+
if (filteredContents.Count == 0)
127+
{
128+
return null;
129+
}
130+
131+
return new ChatResponseUpdate
132+
{
133+
Contents = filteredContents,
134+
CreatedAt = update.CreatedAt,
135+
FinishReason = update.FinishReason,
136+
ModelId = update.ModelId,
137+
RawRepresentation = update.RawRepresentation,
138+
ResponseId = update.ResponseId,
139+
Role = update.Role
140+
};
141+
}
142+
143+
// Check if this is from a tool role - skip it
144+
if (update.Role == ChatRole.Tool)
145+
{
146+
return null;
147+
}
148+
149+
return update;
150+
}
151+
}

src/Knowledge.Shared/Extensions/WebApplicationBuilderExtensions.cs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.AspNetCore.Builder;
22
using Microsoft.AspNetCore.Hosting;
3+
using Microsoft.AspNetCore.Http;
34
using Microsoft.Extensions.Configuration;
45
using Microsoft.Extensions.DependencyInjection;
56
using Microsoft.Extensions.Hosting;
@@ -80,6 +81,39 @@ public static WebApplication ConfigureKnowledgePipeline(this WebApplication app)
8081
{
8182
var knowledgeSettings = app.Services.GetRequiredService<IOptions<KnowledgeSettings>>().Value;
8283

84+
// Add exception handling middleware first to catch all errors
85+
if (app.Environment.IsDevelopment())
86+
{
87+
app.UseDeveloperExceptionPage();
88+
}
89+
else
90+
{
91+
app.UseExceptionHandler(errorApp =>
92+
{
93+
errorApp.Run(async context =>
94+
{
95+
var logger = context.RequestServices.GetRequiredService<ILogger<KnowledgeSettings>>();
96+
var exceptionFeature = context.Features.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
97+
98+
if (exceptionFeature != null)
99+
{
100+
logger.LogError(exceptionFeature.Error,
101+
"Unhandled exception occurred while processing request {Method} {Path}",
102+
context.Request.Method,
103+
context.Request.Path);
104+
}
105+
106+
context.Response.StatusCode = 500;
107+
context.Response.ContentType = "application/json";
108+
await context.Response.WriteAsJsonAsync(new
109+
{
110+
error = "An internal server error occurred.",
111+
requestId = context.TraceIdentifier
112+
});
113+
});
114+
});
115+
}
116+
83117
if (app.Environment.IsDevelopment())
84118
{
85119
app.UseSwagger();
@@ -115,7 +149,6 @@ public static WebApplication LogStartupComplete(this WebApplication app)
115149
logger.LogInformation(" Conversation: {ConversationId}", ConversationWorkaround.CurrentConversationId);
116150
logger.LogInformation(" Available endpoints:");
117151
logger.LogInformation(" • Home: {BaseUrl}/", baseUrl);
118-
logger.LogInformation(" • DevUI: {BaseUrl}/devui", baseUrl);
119152
if (app.Environment.IsDevelopment())
120153
{
121154
logger.LogInformation(" • Swagger: {BaseUrl}/swagger", baseUrl);

src/Knowledge.Shared/Logging/LoggingConfiguration.cs

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ public static ILoggingBuilder ConfigureSharedLogging(this ILoggingBuilder builde
4444

4545
builder.ClearProviders();
4646

47+
// Suppress verbose EF Core logging by default
48+
builder.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
49+
4750
if (options.EnableConsole)
4851
{
4952
builder.AddConsole(opt =>
@@ -98,9 +101,8 @@ public override void Write<TState>(in LogEntry<TState> logEntry, IExternalScopeP
98101

99102
var timestamp = DateTime.Now.ToString("[yyyy-MM-dd HH:mm:ss]");
100103
var logLevel = GetLogLevelString(logEntry.LogLevel);
101-
var category = SimplifyCategory(logEntry.Category);
102104

103-
textWriter.WriteLine($"{timestamp} {logLevel}: {category} {message}");
105+
textWriter.WriteLine($"{timestamp} {logLevel}: {logEntry.Category} {message}");
104106

105107
if (logEntry.Exception is not null)
106108
{
@@ -119,31 +121,6 @@ public override void Write<TState>(in LogEntry<TState> logEntry, IExternalScopeP
119121
LogLevel.None => "none",
120122
_ => "????"
121123
};
122-
123-
private static string SimplifyCategory(string category)
124-
{
125-
// For Knowledge.* categories, use just the class name
126-
if (category.StartsWith("Knowledge.", StringComparison.Ordinal))
127-
{
128-
var lastDot = category.LastIndexOf('.');
129-
return lastDot >= 0 ? category[(lastDot + 1)..] : category;
130-
}
131-
132-
// For Microsoft.Hosting.Lifetime, simplify to Hosting
133-
if (category.Equals("Microsoft.Hosting.Lifetime", StringComparison.Ordinal))
134-
{
135-
return "Hosting";
136-
}
137-
138-
// For other Microsoft categories, use last segment
139-
if (category.StartsWith("Microsoft.", StringComparison.Ordinal))
140-
{
141-
var lastDot = category.LastIndexOf('.');
142-
return lastDot >= 0 ? category[(lastDot + 1)..] : category;
143-
}
144-
145-
return category;
146-
}
147124
}
148125

149126
/// <summary>

0 commit comments

Comments
 (0)