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.
This commit is contained in:
2026-04-04 04:09:57 +02:00
parent dcdd77b5ae
commit a4ed65e452
18 changed files with 456 additions and 0 deletions
@@ -0,0 +1,20 @@
using System.Reflection;
using Microsoft.Extensions.Configuration;
namespace Luna.Configuration.Services.Agents;
internal class AgentOptionsConfigurationProvider(IEnumerable<AgentOptions> 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();
}
}
}
}
@@ -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());
}
@@ -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<AgentOptions> GetAgentOptions()
{
return string.IsNullOrWhiteSpace(searchPath)
? GetAgentOptionsFromEmbeddedResources()
: GetAgentOptionsFromFiles();
}
private IEnumerable<AgentOptions> 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<AgentOptions>(reader);
yield return agentOptions
?? throw new FormatException($"Invalid agent config file: {file}");
}
}
private IEnumerable<AgentOptions> GetAgentOptionsFromEmbeddedResources()
{
var resources = embeddedResourceLoader.ListResources("Agent.*.toml");
foreach (var resource in resources)
{
var content = embeddedResourceLoader.LoadAsync(resource).GetAwaiter().GetResult();
var agentOptions = TomlSerializer.Deserialize<AgentOptions>(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}");
}
}
@@ -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<string> 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<string> 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();
}
}
@@ -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();
}
}
}
}
}
@@ -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());
}
@@ -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<ProviderOptions>(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<ProviderOptions>(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}");
}
}