From a4ed65e4522a4830f3ec918bc48881feff0d5f2f Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 4 Apr 2026 04:09:57 +0200 Subject: [PATCH] Add Luna.Configuration with TOML-based agent and provider config Implement configuration system with embedded TOML resources for agent and provider definitions, custom IConfigurationSource/Provider for agent and provider options, and DI extension methods. Includes config models for agents, providers, sessions, and Telegram integration. --- Luna.Configuration/AgentOptions.cs | 12 ++++ .../ConfigurationBuilderExtensions.cs | 40 +++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 72 +++++++++++++++++++ Luna.Configuration/Luna.Configuration.csproj | 22 ++++++ Luna.Configuration/ProviderOptions.cs | 8 +++ .../Resources/Agents/Agent.Core.toml | 19 +++++ .../Resources/Agents/Agent.Librarian.toml | 13 ++++ .../Resources/Providers/Provider.Mistral.toml | 5 ++ .../Resources/Providers/Provider.Ollama.toml | 5 ++ .../AgentOptionsConfigurationProvider.cs | 20 ++++++ .../Agents/AgentOptionsConfigurationSource.cs | 12 ++++ .../Services/Agents/AgentOptionsProvider.cs | 60 ++++++++++++++++ .../Services/EmbeddedResourceLoader.cs | 44 ++++++++++++ .../ProviderOptionsConfigurationProvider.cs | 32 +++++++++ .../ProviderOptionsConfigurationSource.cs | 13 ++++ .../Providers/ProviderOptionsProvider.cs | 63 ++++++++++++++++ Luna.Configuration/SessionOptions.cs | 7 ++ Luna.Configuration/TelegramOptions.cs | 9 +++ 18 files changed, 456 insertions(+) create mode 100644 Luna.Configuration/AgentOptions.cs create mode 100644 Luna.Configuration/Extensions/ConfigurationBuilderExtensions.cs create mode 100644 Luna.Configuration/Extensions/ServiceCollectionExtensions.cs create mode 100644 Luna.Configuration/Luna.Configuration.csproj create mode 100644 Luna.Configuration/ProviderOptions.cs create mode 100644 Luna.Configuration/Resources/Agents/Agent.Core.toml create mode 100644 Luna.Configuration/Resources/Agents/Agent.Librarian.toml create mode 100644 Luna.Configuration/Resources/Providers/Provider.Mistral.toml create mode 100644 Luna.Configuration/Resources/Providers/Provider.Ollama.toml create mode 100644 Luna.Configuration/Services/Agents/AgentOptionsConfigurationProvider.cs create mode 100644 Luna.Configuration/Services/Agents/AgentOptionsConfigurationSource.cs create mode 100644 Luna.Configuration/Services/Agents/AgentOptionsProvider.cs create mode 100644 Luna.Configuration/Services/EmbeddedResourceLoader.cs create mode 100644 Luna.Configuration/Services/Providers/ProviderOptionsConfigurationProvider.cs create mode 100644 Luna.Configuration/Services/Providers/ProviderOptionsConfigurationSource.cs create mode 100644 Luna.Configuration/Services/Providers/ProviderOptionsProvider.cs create mode 100644 Luna.Configuration/SessionOptions.cs create mode 100644 Luna.Configuration/TelegramOptions.cs diff --git a/Luna.Configuration/AgentOptions.cs b/Luna.Configuration/AgentOptions.cs new file mode 100644 index 0000000..e2fbcef --- /dev/null +++ b/Luna.Configuration/AgentOptions.cs @@ -0,0 +1,12 @@ +namespace Luna.Configuration; + +public class AgentOptions +{ + public required string Name { get; set; } + public string? DisplayName { get; init; } + public string? Description { get; init; } + public required string Provider { get; init; } + public required string ModelId { get; init; } + public required string Instructions { get; init; } + public required int MaxContextTokens { get; init; } +} \ No newline at end of file diff --git a/Luna.Configuration/Extensions/ConfigurationBuilderExtensions.cs b/Luna.Configuration/Extensions/ConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..a346af3 --- /dev/null +++ b/Luna.Configuration/Extensions/ConfigurationBuilderExtensions.cs @@ -0,0 +1,40 @@ +using Luna.Configuration.Services.Agents; +using Luna.Configuration.Services.Providers; +using Microsoft.Extensions.Configuration; + +namespace Luna.Configuration.Extensions; + +public static class ConfigurationBuilderExtensions +{ + private static string[] AgentConfigSearchPaths = + [ + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".luna", "agents") + ]; + + private static string[] ProviderConfigSearchPaths = + [ + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".luna", "providers") + ]; + + extension(IConfigurationBuilder builder) + { + public IConfigurationBuilder AddLunaConfiguration() + { + builder.Add(new AgentOptionsConfigurationSource()); + + foreach (var searchPath in AgentConfigSearchPaths) + { + builder.Add(new AgentOptionsConfigurationSource(searchPath)); + } + + builder.Add(new ProviderOptionsConfigurationSource()); + + foreach (var searchPath in ProviderConfigSearchPaths) + { + builder.Add(new ProviderOptionsConfigurationSource(searchPath)); + } + + return builder; + } + } +} \ No newline at end of file diff --git a/Luna.Configuration/Extensions/ServiceCollectionExtensions.cs b/Luna.Configuration/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..51a7b40 --- /dev/null +++ b/Luna.Configuration/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Luna.Configuration.Extensions; + +public static class ServiceCollectionExtensions +{ + extension(IServiceCollection services) + { + public IServiceCollection AddProviderOptions(string? name) + { + services.AddOptions(name) + .BindConfiguration($"Providers:{name}") + .Validate( + options => !string.IsNullOrWhiteSpace(options.ApiKey), + $"{name} options missing ApiKey") + .Validate( + options => !string.IsNullOrWhiteSpace(options.ApiUrl), + $"{name} options missing ApiUrl") + .Validate( + options => options.Models is { Length: > 0 }, + $"{name} options missing Model definitions") + .ValidateOnStart(); + + return services; + } + + public IServiceCollection AddAgentOptions(string? name) + { + services.AddOptions(name) + .BindConfiguration($"Agents:{name}") + .Validate( + options => !string.IsNullOrWhiteSpace(options.Provider), + $"{name} options missing Provider") + .Validate( + options => !string.IsNullOrWhiteSpace(options.ModelId), + $"{name} options missing ModelId") + .Validate( + options => !string.IsNullOrWhiteSpace(options.Instructions), + $"{name} options missing Instructions") + .Validate( + options => options.MaxContextTokens > 0, + $"{name} options must have MaxContextTokens greater than 0") + .ValidateOnStart(); + + return services; + } + + public IServiceCollection AddSessionOptions() + { + services.AddOptions() + .BindConfiguration("Session") + .Validate( + options => options.ContextTokenThreshold > 0, + "Session options must have ContextTokenThreshold greater than 0") + .ValidateOnStart(); + + return services; + } + + public IServiceCollection AddTelegramOptions() + { + services.AddOptions() + .BindConfiguration("Channels:Telegram") + .Validate( + options => !string.IsNullOrWhiteSpace(options.BotToken), + "Telegram options missing BotToken") + .ValidateOnStart(); + + return services; + } + } +} diff --git a/Luna.Configuration/Luna.Configuration.csproj b/Luna.Configuration/Luna.Configuration.csproj new file mode 100644 index 0000000..4cca5d5 --- /dev/null +++ b/Luna.Configuration/Luna.Configuration.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/Luna.Configuration/ProviderOptions.cs b/Luna.Configuration/ProviderOptions.cs new file mode 100644 index 0000000..ef3c71d --- /dev/null +++ b/Luna.Configuration/ProviderOptions.cs @@ -0,0 +1,8 @@ +namespace Luna.Configuration; + +public class ProviderOptions +{ + public required string ApiKey { get; init; } + public required string ApiUrl { get; init; } + public required string[] Models { get; init; } +} \ No newline at end of file diff --git a/Luna.Configuration/Resources/Agents/Agent.Core.toml b/Luna.Configuration/Resources/Agents/Agent.Core.toml new file mode 100644 index 0000000..09b70ca --- /dev/null +++ b/Luna.Configuration/Resources/Agents/Agent.Core.toml @@ -0,0 +1,19 @@ + +Name = "Core" +Description = ''' +The Core Agent is responsible for managing the overall system and coordinating the actions of other agents. +''' +Provider = "Mistral" +ModelId = "mistral-large-latest" +MaxContextTokens = 256_000 +Instructions = ''' +## L.U.N.A. (Lovely Unit for Nerdy Assistance) + +You are Luna, an AI personal assistant. + +# Core Identity + +* Role: Personal assistance, researcher or co-programmer +* Personality: Calm, witty, organized, supportive +* Communication: Concise, structured, encouraging +''' \ No newline at end of file diff --git a/Luna.Configuration/Resources/Agents/Agent.Librarian.toml b/Luna.Configuration/Resources/Agents/Agent.Librarian.toml new file mode 100644 index 0000000..16188f3 --- /dev/null +++ b/Luna.Configuration/Resources/Agents/Agent.Librarian.toml @@ -0,0 +1,13 @@ + +Name = "Librarian" +Description = ''' +The Librarian Agent is responsible for summarizing and condensing the conversation history into concise context for future interactions. +''' +Provider = "Mistral" +ModelId = "mistral-small-latest" +MaxContextTokens = 256_000 +Instructions = ''' +You are a conversation compaction engine. +Summarize older chat history into concise context for future turns. +Preserve: user preferences, commitments, decisions, unresolved tasks, key facts, file paths, and identifiers. Omit: filler, repeated chit-chat, verbose tool logs. Output plain text bullet points only. +''' \ No newline at end of file diff --git a/Luna.Configuration/Resources/Providers/Provider.Mistral.toml b/Luna.Configuration/Resources/Providers/Provider.Mistral.toml new file mode 100644 index 0000000..5a78412 --- /dev/null +++ b/Luna.Configuration/Resources/Providers/Provider.Mistral.toml @@ -0,0 +1,5 @@ + +ApiKey = "REDACTED-MISTRAL-API-KEY" +ApiUrl = "https://api.mistral.ai/v1/" +Models = ["mistral-small-latest"] + diff --git a/Luna.Configuration/Resources/Providers/Provider.Ollama.toml b/Luna.Configuration/Resources/Providers/Provider.Ollama.toml new file mode 100644 index 0000000..4cb1a53 --- /dev/null +++ b/Luna.Configuration/Resources/Providers/Provider.Ollama.toml @@ -0,0 +1,5 @@ + +ApiKey = "apfelkuchen" +ApiUrl = "http://localhost:11434/" +Models = ["mistral-nemo:12b"] + diff --git a/Luna.Configuration/Services/Agents/AgentOptionsConfigurationProvider.cs b/Luna.Configuration/Services/Agents/AgentOptionsConfigurationProvider.cs new file mode 100644 index 0000000..c6f0e87 --- /dev/null +++ b/Luna.Configuration/Services/Agents/AgentOptionsConfigurationProvider.cs @@ -0,0 +1,20 @@ +using System.Reflection; +using Microsoft.Extensions.Configuration; + +namespace Luna.Configuration.Services.Agents; + +internal class AgentOptionsConfigurationProvider(IEnumerable agentOptions) : ConfigurationProvider +{ + public override void Load() + { + foreach (var options in agentOptions) + { + var keyPrefix = $"Agents:{options.Name}"; + + foreach (var property in options.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + Data[$"{keyPrefix}:{property.Name}"] = property.GetValue(options)?.ToString(); + } + } + } +} \ No newline at end of file diff --git a/Luna.Configuration/Services/Agents/AgentOptionsConfigurationSource.cs b/Luna.Configuration/Services/Agents/AgentOptionsConfigurationSource.cs new file mode 100644 index 0000000..ef5dc4e --- /dev/null +++ b/Luna.Configuration/Services/Agents/AgentOptionsConfigurationSource.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Configuration; + +namespace Luna.Configuration.Services.Agents; + +internal class AgentOptionsConfigurationSource(string? searchPath = null) + : IConfigurationSource +{ + private readonly AgentOptionsProvider optionsProvider = new(searchPath); + + public IConfigurationProvider Build(IConfigurationBuilder builder) + => new AgentOptionsConfigurationProvider(optionsProvider.GetAgentOptions()); +} \ No newline at end of file diff --git a/Luna.Configuration/Services/Agents/AgentOptionsProvider.cs b/Luna.Configuration/Services/Agents/AgentOptionsProvider.cs new file mode 100644 index 0000000..e565ae4 --- /dev/null +++ b/Luna.Configuration/Services/Agents/AgentOptionsProvider.cs @@ -0,0 +1,60 @@ +using System.Text.RegularExpressions; +using Tomlyn; + +namespace Luna.Configuration.Services.Agents; + +internal sealed class AgentOptionsProvider(string? searchPath = null) +{ + private readonly EmbeddedResourceLoader embeddedResourceLoader = new(); + + public IEnumerable GetAgentOptions() + { + return string.IsNullOrWhiteSpace(searchPath) + ? GetAgentOptionsFromEmbeddedResources() + : GetAgentOptionsFromFiles(); + } + + private IEnumerable GetAgentOptionsFromFiles() + { + ArgumentException.ThrowIfNullOrEmpty(searchPath); + + var files = Directory.Exists(searchPath) + ? Directory.EnumerateFiles(searchPath, "Agent.*.toml") + : []; + + foreach (var file in files) + { + using var reader = File.OpenText(file); + var agentOptions = TomlSerializer.Deserialize(reader); + + yield return agentOptions + ?? throw new FormatException($"Invalid agent config file: {file}"); + } + } + + private IEnumerable GetAgentOptionsFromEmbeddedResources() + { + var resources = embeddedResourceLoader.ListResources("Agent.*.toml"); + foreach (var resource in resources) + { + var content = embeddedResourceLoader.LoadAsync(resource).GetAwaiter().GetResult(); + var agentOptions = TomlSerializer.Deserialize(content); + + yield return agentOptions + ?? throw new FormatException($"Invalid agent config embedded resource: {resource}"); + } + } +} + +internal static partial class StringExtensions +{ + [GeneratedRegex(@"Agent\.(.+)\.toml", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex AgentNameRegex { get; } + + extension(string str) + { + public string ToAgentName() => AgentNameRegex.Match(str) is { Success: true } match + ? match.Groups[1].Value + : throw new FormatException($"Invalid agent config file name: {str}"); + } +} \ No newline at end of file diff --git a/Luna.Configuration/Services/EmbeddedResourceLoader.cs b/Luna.Configuration/Services/EmbeddedResourceLoader.cs new file mode 100644 index 0000000..ba21e9b --- /dev/null +++ b/Luna.Configuration/Services/EmbeddedResourceLoader.cs @@ -0,0 +1,44 @@ +using System.Collections.Immutable; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace Luna.Configuration.Services; + +public sealed class EmbeddedResourceLoader +{ + private readonly Assembly? assembly = Assembly.GetExecutingAssembly(); + + public ImmutableHashSet ListResources(string? pattern = null) + { + if (assembly is null) + throw new InvalidOperationException("Unable to determine the entry assembly."); + + var resourceNames = assembly.GetManifestResourceNames(); + + if (string.IsNullOrEmpty(pattern)) + return resourceNames.ToImmutableHashSet(); + + var regex = new Regex(Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".?"), RegexOptions.IgnoreCase); + return resourceNames + .Where(r => regex.IsMatch(r)) + .ToImmutableHashSet(); + } + + public async Task LoadAsync(string name) + { + if (assembly is null) + throw new InvalidOperationException("Unable to determine the entry assembly."); + + var resourceName = assembly.GetManifestResourceNames().FirstOrDefault(r => r.EndsWith(name, StringComparison.OrdinalIgnoreCase)); + + if (resourceName == null) + throw new FileNotFoundException($"Embedded resource '{name}' not found."); + + await using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + throw new FileNotFoundException($"Unable to load embedded resource '{name}'."); + + using var reader = new StreamReader(stream); + return await reader.ReadToEndAsync(); + } +} \ No newline at end of file diff --git a/Luna.Configuration/Services/Providers/ProviderOptionsConfigurationProvider.cs b/Luna.Configuration/Services/Providers/ProviderOptionsConfigurationProvider.cs new file mode 100644 index 0000000..26d600c --- /dev/null +++ b/Luna.Configuration/Services/Providers/ProviderOptionsConfigurationProvider.cs @@ -0,0 +1,32 @@ +using System.Reflection; +using Microsoft.Extensions.Configuration; + +namespace Luna.Configuration.Services.Providers; + +internal class ProviderOptionsConfigurationProvider(IEnumerable<(string Name, ProviderOptions Options)> providerOptions) + : ConfigurationProvider +{ + public override void Load() + { + foreach (var (name, options) in providerOptions) + { + var keyPrefix = $"Providers:{name}"; + + foreach (var property in options.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + var value = property.GetValue(options); + + if (value is string[] array) + { + for (var i = 0; i < array.Length; i++) + Data[$"{keyPrefix}:{property.Name}:{i}"] = array[i]; + } + else + { + Data[$"{keyPrefix}:{property.Name}"] = value?.ToString(); + } + } + } + } +} + diff --git a/Luna.Configuration/Services/Providers/ProviderOptionsConfigurationSource.cs b/Luna.Configuration/Services/Providers/ProviderOptionsConfigurationSource.cs new file mode 100644 index 0000000..bb751e6 --- /dev/null +++ b/Luna.Configuration/Services/Providers/ProviderOptionsConfigurationSource.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Configuration; + +namespace Luna.Configuration.Services.Providers; + +internal class ProviderOptionsConfigurationSource(string? searchPath = null) + : IConfigurationSource +{ + private readonly ProviderOptionsProvider optionsProvider = new(searchPath); + + public IConfigurationProvider Build(IConfigurationBuilder builder) + => new ProviderOptionsConfigurationProvider(optionsProvider.GetProviderOptions()); +} + diff --git a/Luna.Configuration/Services/Providers/ProviderOptionsProvider.cs b/Luna.Configuration/Services/Providers/ProviderOptionsProvider.cs new file mode 100644 index 0000000..925c97d --- /dev/null +++ b/Luna.Configuration/Services/Providers/ProviderOptionsProvider.cs @@ -0,0 +1,63 @@ +using System.Text.RegularExpressions; +using Tomlyn; + +namespace Luna.Configuration.Services.Providers; + +internal sealed class ProviderOptionsProvider(string? searchPath = null) +{ + private readonly EmbeddedResourceLoader embeddedResourceLoader = new(); + + public IEnumerable<(string Name, ProviderOptions Options)> GetProviderOptions() + { + return string.IsNullOrWhiteSpace(searchPath) + ? GetProviderOptionsFromEmbeddedResources() + : GetProviderOptionsFromFiles(); + } + + private IEnumerable<(string Name, ProviderOptions Options)> GetProviderOptionsFromFiles() + { + ArgumentException.ThrowIfNullOrEmpty(searchPath); + + var files = Directory.Exists(searchPath) + ? Directory.EnumerateFiles(searchPath, "Provider.*.toml") + : []; + + foreach (var file in files) + { + var name = Path.GetFileName(file).ToProviderName(); + using var reader = File.OpenText(file); + var providerOptions = TomlSerializer.Deserialize(reader); + + yield return (name, providerOptions + ?? throw new FormatException($"Invalid provider config file: {file}")); + } + } + + private IEnumerable<(string Name, ProviderOptions Options)> GetProviderOptionsFromEmbeddedResources() + { + var resources = embeddedResourceLoader.ListResources("Provider.*.toml"); + foreach (var resource in resources) + { + var name = resource.ToProviderName(); + var content = embeddedResourceLoader.LoadAsync(resource).GetAwaiter().GetResult(); + var providerOptions = TomlSerializer.Deserialize(content); + + yield return (name, providerOptions + ?? throw new FormatException($"Invalid provider config embedded resource: {resource}")); + } + } +} + +internal static partial class ProviderStringExtensions +{ + [GeneratedRegex(@"Provider\.(.+)\.toml", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex ProviderNameRegex { get; } + + extension(string str) + { + public string ToProviderName() => ProviderNameRegex.Match(str) is { Success: true } match + ? match.Groups[1].Value + : throw new FormatException($"Invalid provider config file name: {str}"); + } +} + diff --git a/Luna.Configuration/SessionOptions.cs b/Luna.Configuration/SessionOptions.cs new file mode 100644 index 0000000..3bafce2 --- /dev/null +++ b/Luna.Configuration/SessionOptions.cs @@ -0,0 +1,7 @@ +namespace Luna.Configuration; + +public class SessionOptions +{ + public required float ContextTokenThreshold { get; init; } + public required int RetainedMessagesAfterCompacting { get; init; } +} \ No newline at end of file diff --git a/Luna.Configuration/TelegramOptions.cs b/Luna.Configuration/TelegramOptions.cs new file mode 100644 index 0000000..97ba4c2 --- /dev/null +++ b/Luna.Configuration/TelegramOptions.cs @@ -0,0 +1,9 @@ +namespace Luna.Configuration; + +public class TelegramOptions +{ + public required string BotToken { get; init; } + public string? WebhookUrl { get; init; } + public int PollingTimeoutSeconds { get; init; } = 30; + public string[] AllowedUserIds { get; init; } = []; +}