Add Luna.Core server with SignalR hub, session management, and tool system

Implement the core application server as an ASP.NET web host with:
- SignalR ChatHub for real-time client communication
- Session management with token estimation for context windowing
- Extensible tool system with attribute-based tool discovery
- Chat stream update builder for streaming AI responses
- App configuration with appsettings for development and production
This commit is contained in:
2026-04-04 04:10:58 +02:00
parent fc71d848e2
commit bc25dc7b07
23 changed files with 586 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
using Luna.Shared;
namespace Luna.Core.Abstractions;
public interface IChatHubContext
{
Task SendResponseAsync(string connectionId, ChannelMessage message, CancellationToken cancellationToken = default);
Task SendStreamingResponseAsync(
string connectionId,
IAsyncEnumerable<ChatStreamUpdate> messageStream,
CancellationToken cancellationToken = default);
}
+12
View File
@@ -0,0 +1,12 @@
using Luna.Shared;
namespace Luna.Core.Abstractions;
public interface ISessionManager
{
IAsyncEnumerable<ChatStreamUpdate> RouteMessagesAsync(string content,
string conversationId,
string connectionId,
CancellationToken ct = default);
Task ClientDisconnectedAsync(string connectionId);
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Luna.Shared\Luna.Shared.csproj" />
</ItemGroup>
</Project>
+49
View File
@@ -0,0 +1,49 @@
using Luna.Channels.Abstractions;
using Luna.Channels.Cli;
using Luna.Channels.Web;
using Luna.Core.Abstractions;
using Luna.Shared;
using Microsoft.AspNetCore.SignalR;
namespace Luna.Core.Hubs;
public class ChatHub(IChannelManager channelManager, IChatHubContext hubContext) : Hub
{
public override Task OnConnectedAsync()
{
var clientType = Context.GetHttpContext()?.Request.Query["clientType"].ToString();
IChannel channel = clientType switch
{
"web" => new WebChannel(Context.ConnectionId, hubContext),
_ => new CliChannel(Context.ConnectionId, hubContext)
};
channelManager.RegisterChannel(channel);
return base.OnConnectedAsync();
}
public override Task OnDisconnectedAsync(Exception? exception)
{
channelManager.UnregisterChannel(Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
public void OnMessageReceived(ChannelMessage message)
{
var channel = channelManager.GetChannel(Context.ConnectionId);
switch (channel)
{
case CliChannel cli:
cli.RaiseMessageReceived(this, message);
break;
case WebChannel web:
web.RaiseMessageReceived(this, message);
break;
default:
throw new InvalidOperationException(
$"ChannelManager returned channel of unexpected type: {channel?.GetType().Name ?? "null"}");
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using Luna.Core.Abstractions;
using Luna.Shared;
using Microsoft.AspNetCore.SignalR;
namespace Luna.Core.Hubs;
public class ChatHubContext(IHubContext<ChatHub> hubContext) : IChatHubContext
{
public async Task SendResponseAsync(string connectionId, ChannelMessage message, CancellationToken cancellationToken = default)
{
await GetClient(connectionId)
.SendAsync("OnMessageReceived", message, cancellationToken);
}
public async Task SendStreamingResponseAsync(
string connectionId,
IAsyncEnumerable<ChatStreamUpdate> messageStream,
CancellationToken cancellationToken = default)
{
var client = GetClient(connectionId);
await foreach (var update in messageStream.WithCancellation(cancellationToken))
{
await client.SendAsync("OnMessageStreamReceived", update, cancellationToken);
}
}
private ISingleClientProxy GetClient(string connectionId)
=> hubContext.Clients.Client(connectionId);
}
+28
View File
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4"/>
<PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Luna.Agents.Abstractions\Luna.Agents.Abstractions.csproj" />
<ProjectReference Include="..\Luna.Agents\Luna.Agents.csproj" />
<ProjectReference Include="..\Luna.Channels.Abstractions\Luna.Channels.Abstractions.csproj" />
<ProjectReference Include="..\Luna.Channels\Luna.Channels.csproj" />
<ProjectReference Include="..\Luna.Configuration\Luna.Configuration.csproj" />
<ProjectReference Include="..\Luna.Core.Abstractions\Luna.Core.Abstractions.csproj" />
<ProjectReference Include="..\Luna.Memory\Luna.Memory.csproj" />
<ProjectReference Include="..\Luna.Providers.Abstractions\Luna.Providers.Abstractions.csproj" />
<ProjectReference Include="..\Luna.Providers\Luna.Providers.csproj" />
<ProjectReference Include="..\Luna.Shared\Luna.Shared.csproj" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@Luna.Core_HostAddress = http://localhost:5247
GET {{Luna.Core_HostAddress}}/weatherforecast/
Accept: application/json
###
+50
View File
@@ -0,0 +1,50 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Luna.Agents.Extensions;
using Luna.Channels.Extensions;
using Luna.Configuration.Extensions;
using Luna.Core.Abstractions;
using Luna.Core.Hubs;
using Luna.Core.Services.Extensions;
using Luna.Core.Tools.Extensions;
using Luna.Memory.Extensions;
using Luna.Providers.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddLunaConfiguration();
builder.Services
.AddOpenApi()
.AddProviders()
.AddAgents()
.AddTools()
.AddMemoryStore()
.AddSessionManager()
.AddChannels();
builder.Services.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.Converters.Add(
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
});
builder.Services.AddTransient<IChatHubContext, ChatHubContext>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapHub<ChatHub>("/chat");
app.Run();
// Expose Program type for WebApplicationFactory in integration tests
namespace Luna.Core
{
public partial class Program { }
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5247",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7203;http://localhost:5247",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,29 @@
using Luna.Shared;
using Microsoft.Extensions.AI;
namespace Luna.Core.Services;
public class ChatStreamUpdateBuilder : IChatStreamUpdateBuilder
{
public ChatStreamUpdate FromAiContent(AIContent content, string messageId)
=> content switch
{
TextReasoningContent reasoning
=> new ChatStreamUpdate(ChatStreamUpdateType.Text, reasoning.Text, "assistant", false, messageId),
TextContent text
=> new ChatStreamUpdate(ChatStreamUpdateType.Text, text.Text, "assistant", false, messageId),
ToolCallContent toolCall
=> new ChatStreamUpdate(ChatStreamUpdateType.ToolCall, toolCall.ToString(), "assistant", false, messageId),
ToolResultContent toolResult
=> new ChatStreamUpdate(ChatStreamUpdateType.ToolResult, toolResult.ToString(), "assistant", false, messageId),
UsageContent usage
=> new ChatStreamUpdate(ChatStreamUpdateType.Usage, usage.ToString(), "system", false, messageId),
_ => throw new NotSupportedException($"Unsupported content type: {content.GetType().Name}")
};
public ChatStreamUpdate FromString(string content, string messageId)
=> new(ChatStreamUpdateType.Text, content, "assistant", false, messageId);
public ChatStreamUpdate CreateCompleteUpdate(string messageId)
=> new(ChatStreamUpdateType.Complete, null, null, true, messageId);
}
@@ -0,0 +1,21 @@
using Luna.Configuration.Extensions;
using Luna.Core.Abstractions;
using Luna.Core.Session;
namespace Luna.Core.Services.Extensions;
public static class ServiceCollectionExtensions
{
extension(IServiceCollection services)
{
public IServiceCollection AddSessionManager()
{
services.AddTransient<IChatStreamUpdateBuilder, ChatStreamUpdateBuilder>();
services.AddSessionOptions();
services.AddSingleton<ISessionManager, SessionManager>();
return services;
}
}
}
@@ -0,0 +1,11 @@
using Luna.Shared;
using Microsoft.Extensions.AI;
namespace Luna.Core.Services;
public interface IChatStreamUpdateBuilder
{
ChatStreamUpdate FromAiContent(AIContent content, string messageId);
ChatStreamUpdate FromString(string content, string messageId);
ChatStreamUpdate CreateCompleteUpdate(string messageId);
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.Extensions.AI;
namespace Luna.Core.Session;
public class Session
{
public required string SessionId { get; set; }
public required List<ChatMessage> Messages { get; set; }
public int TokenAmount => TokenEstimator.EstimateTokens(Messages);
}
+160
View File
@@ -0,0 +1,160 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Text;
using Luna.Agents.Abstractions;
using Luna.Core.Abstractions;
using Luna.Core.Services;
using Luna.Memory;
using Luna.Shared;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using SessionOptions = Luna.Configuration.SessionOptions;
namespace Luna.Core.Session;
public class SessionManager(
[FromKeyedServices("Core")] IAgent coreAgent,
[FromKeyedServices("Librarian")] IAgent librarianAgent,
IOptions<SessionOptions> sessionOptions,
IChatStreamUpdateBuilder streamUpdateBuilder,
IMemoryStore memoryStore) : ISessionManager
{
private readonly Dictionary<string, Session> sessions = new();
private readonly ConcurrentDictionary<string, string> connectedClients = new();
private const string CompactionPrompt =
"Summarize the following conversation history for context preservation. Keep it short (max 12 bullet points).";
private SessionOptions Options => sessionOptions.Value;
public async IAsyncEnumerable<ChatStreamUpdate> RouteMessagesAsync(
string content,
string conversationId,
string connectionId,
CancellationToken ct = default)
{
if (!connectedClients.ContainsKey(connectionId))
connectedClients[connectionId] = conversationId;
if (!sessions.ContainsKey(conversationId))
{
var memory = new ChatMessage(ChatRole.System, await memoryStore.GetMemoriesAsync());
sessions[conversationId] = new Session
{
SessionId = conversationId,
Messages = string.IsNullOrWhiteSpace(memory.Text)
? []
: [memory]
};
}
var session = sessions[conversationId];
var coreOptions = coreAgent.Options;
if (session.TokenAmount > coreOptions.MaxContextTokens * Options.ContextTokenThreshold)
await CompactSessionAsync(conversationId, Options.RetainedMessagesAfterCompacting, ct);
session.Messages.Add(new ChatMessage(ChatRole.User, content));
var messageId = Guid.NewGuid().ToString();
var responseBuilder = new StringBuilder();
await foreach (var update in coreAgent.ProcessStreamingAsync(session.Messages, ct))
{
if (update.Contents is { Count: > 0 })
{
foreach (var chunk in update.Contents)
{
var streamUpdate = streamUpdateBuilder.FromAiContent(chunk, messageId);
responseBuilder.Append(streamUpdate.Content);
yield return streamUpdate;
}
}
else if (update.Text is { Length: > 0 } text)
{
var streamUpdate = streamUpdateBuilder.FromString(text, messageId);
responseBuilder.Append(streamUpdate.Content);
yield return streamUpdate;
}
}
yield return streamUpdateBuilder.CreateCompleteUpdate(messageId);
session.Messages.Add(new ChatMessage(ChatRole.Assistant, responseBuilder.ToString()));
}
public async Task ClientDisconnectedAsync(string connectionId)
{
if (!connectedClients.TryRemove(connectionId, out var conversationId))
return;
await SaveSessionLogAsync(conversationId);
}
private async Task SaveSessionLogAsync(string conversationId)
{
var session = sessions[conversationId];
var logBuilder = new StringBuilder();
foreach (var message in session.Messages
.Where(m => m.Role != ChatRole.System || m.Role != ChatRole.Tool))
{
logBuilder.AppendLine($"[{message.Role.Value}]:");
logBuilder.AppendLine(message.Text);
logBuilder.AppendLine();
}
await memoryStore.AddMemoryAsync(logBuilder.ToString());
sessions.Remove(conversationId);
}
private async Task CompactSessionAsync(string conversationId, int retainMessages = 0, CancellationToken ct = default)
{
var session = sessions[conversationId];
var retainedMessages = session.Messages.Count > retainMessages
? session.Messages.TakeLast(retainMessages)
: [];
var messagesToCompact = session.Messages.Count > retainMessages
? session.Messages.SkipLast(retainMessages)
: session.Messages;
var compactionMessage = BuildCompactionMessage(messagesToCompact.ToList());
var response = await librarianAgent.ProcessAsync([compactionMessage], ct);
var memoryBuilder = new StringBuilder();
memoryBuilder.AppendLine("<---- MEMORY BEGIN ---->");
memoryBuilder.AppendLine($"[Meta] Conversation Recorded at: {DateTime.Now.ToString(CultureInfo.CurrentCulture)}");
foreach (var message in response)
{
if (message.Role != ChatRole.System)
{
memoryBuilder.AppendLine(message.Text);
}
}
memoryBuilder.AppendLine("<---- MEMORY END ---->");
session.Messages = [
new ChatMessage(ChatRole.Assistant, memoryBuilder.ToString()),
];
session.Messages.AddRange(retainedMessages);
}
private ChatMessage BuildCompactionMessage(IReadOnlyList<ChatMessage> messages)
{
var builder = new StringBuilder();
builder.AppendLine(CompactionPrompt);
builder.AppendLine();
builder.AppendLine("<---- CONVERSATION BEGIN ---->");
builder.AppendLine();
foreach (var message in messages)
{
if (message.Text.Contains("<---- MEMORY BEGIN ---->"))
continue;
builder.AppendLine($"{message.Role}: {message.Text}");
}
builder.AppendLine();
builder.AppendLine("<---- CONVERSATION END ---->");
return new ChatMessage(ChatRole.User, builder.ToString());
}
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.Extensions.AI;
namespace Luna.Core.Session;
public static class TokenEstimator
{
public static int EstimateTokens(string text)
=> text.Length / 4;
public static int EstimateTokens(IReadOnlyList<ChatMessage> messages)
=> messages.Sum(m => EstimateTokens(m.Text));
}
+24
View File
@@ -0,0 +1,24 @@
namespace Luna.Core.Tools.BuiltIn;
public class CoreTools : IToolbox
{
public string Name => "core";
public record Channel(string Name, string Health, bool IsConnected);
public record ListChannelsResult(IEnumerable<Channel> Channels);
[Tool("list_channels", "List available communication channels")]
public ListChannelsResult ListChannels()
{
return new ListChannelsResult([
new Channel("cli", "healthy", true),
new Channel("web", "healthy", false),
new Channel("telegram", "healthy", false)
]);
}
[Tool("change_channel", "Change the active communication channel")]
public void ChangeChannel(string channelName)
{
}
}
@@ -0,0 +1,19 @@
namespace Luna.Core.Tools.Extensions;
public static class ServiceCollectionExtensions
{
extension(IServiceCollection services)
{
public IServiceCollection AddTools()
{
services.AddSingleton<IToolsProvider, ToolsProvider>();
typeof(ToolsProvider).Assembly.GetTypes()
.Where(t => typeof(IToolbox).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract)
.ToList()
.ForEach(t => services.AddTransient(typeof(IToolbox), t));
return services;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.Reflection;
using Microsoft.Extensions.AI;
namespace Luna.Core.Tools;
public interface IToolbox
{
public string Name { get; }
public sealed IEnumerable<AITool> GetTools()
{
var type = GetType();
List<(ToolAttribute Attribute, MethodInfo Method)> tools = [];
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance))
{
var attribute = method.GetCustomAttribute<ToolAttribute>();
if (attribute is null) continue;
tools.Add((attribute, method));
}
return tools.Select(tool => AIFunctionFactory.Create(
tool.Method,
this,
$"{this.Name}/{tool.Attribute.Name}",
tool.Attribute.Description
));
}
}
+8
View File
@@ -0,0 +1,8 @@
using Microsoft.Extensions.AI;
namespace Luna.Core.Tools;
public interface IToolsProvider
{
IEnumerable<AITool> GetTools();
}
+8
View File
@@ -0,0 +1,8 @@
namespace Luna.Core.Tools;
[AttributeUsage(AttributeTargets.Method)]
public class ToolAttribute(string name, string description) : Attribute
{
public string Name { get; } = name;
public string Description { get; } = description;
}
+9
View File
@@ -0,0 +1,9 @@
using Microsoft.Extensions.AI;
namespace Luna.Core.Tools;
public sealed class ToolsProvider(IEnumerable<IToolbox> toolboxes) : IToolsProvider
{
public IEnumerable<AITool> GetTools()
=> toolboxes.SelectMany(t => t.GetTools());
}
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"Session": {
"ContextTokenThreshold": 0.75,
"RetainedMessagesAfterCompacting": 2
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}