diff options
Diffstat (limited to 'src/Agent.App/Configuration/ConfigurationLoader.cs')
| -rw-r--r-- | src/Agent.App/Configuration/ConfigurationLoader.cs | 85 |
1 files changed, 85 insertions, 0 deletions
diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs new file mode 100644 index 0000000..b6c4c2d --- /dev/null +++ b/src/Agent.App/Configuration/ConfigurationLoader.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Text.RegularExpressions; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Agent.App.Configuration; + +public static partial class ConfigurationLoader +{ + public static AgentConfiguration Load(string path) + { + string absolutePath = Path.GetFullPath(path); + if (!File.Exists(absolutePath)) + { + throw new ConfigurationException($"Configuration file does not exist: {absolutePath}"); + } + + try + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .Build(); + AgentConfiguration configuration = deserializer.Deserialize<AgentConfiguration>(File.ReadAllText(absolutePath)) + ?? throw new ConfigurationException("Configuration document is empty."); + Validate(configuration, Path.GetDirectoryName(absolutePath)!); + return configuration; + } + catch (ConfigurationException) + { + throw; + } + catch (Exception ex) + { + throw new ConfigurationException($"Could not parse YAML: {ex.Message}", ex); + } + } + + public static void Validate(AgentConfiguration value, string baseDirectory) + { + if (value.SchemaVersion != 1) Throw("schema_version must be 1."); + if (string.IsNullOrWhiteSpace(value.Application.Language)) Throw("application.language is required."); + if (value.Mcp.Transport is not ("stdio" or "http")) Throw("mcp.transport must be stdio or http."); + if (!IPAddress.TryParse(value.Mcp.Http.Host, out IPAddress? host) || !IPAddress.IsLoopback(host)) Throw("mcp.http.host must be 127.0.0.1 or ::1."); + if (value.Mcp.Http.Port is < 1 or > 65535) Throw("mcp.http.port is outside 1..65535."); + if (value.Mcp.Images.MaxCount < 0 || value.Mcp.Images.MaxBytesEach <= 0 || value.Mcp.Images.MaxTotalBytes <= 0) Throw("mcp.images limits must be positive."); + if (value.Mcp.Images.MaxTotalBytes < value.Mcp.Images.MaxBytesEach) Throw("mcp.images.max_total_bytes must be >= max_bytes_each."); + if (value.Agent.MaxIterations <= 0 || value.Agent.SameFailureLimit <= 0) Throw("agent iteration limits must be positive."); + if (!Uri.TryCreate(value.Ollama.Endpoint, UriKind.Absolute, out Uri? endpoint) || endpoint.Scheme is not ("http" or "https")) Throw("ollama.endpoint must be an absolute HTTP(S) URI."); + if (string.IsNullOrWhiteSpace(value.Ollama.Model)) Throw("ollama.model is required."); + if (value.Ollama.Options.NumCtx is <= 0) Throw("ollama.options.num_ctx must be positive."); + if (value.Ollama.Options.Temperature is < 0 or > 2) Throw("ollama.options.temperature must be in 0..2."); + if (value.Planner.MaxTasks is < 1 or > 32) Throw("planner.max_tasks must be in 1..32."); + if (value.Sandbox.Process.MaxProcesses <= 0 || value.Sandbox.Resources.MemoryMb <= 0 || value.Sandbox.Resources.ProcessTimeoutSeconds <= 0) Throw("sandbox resource limits must be positive."); + if (value.Sandbox.Network.Mode is not ("deny" or "allow")) Throw("sandbox.network.mode must be deny or allow."); + foreach (NetworkAllowance allowance in value.Sandbox.Network.Allow) + { + if (string.IsNullOrWhiteSpace(allowance.Host) || allowance.Ports.Count == 0 || allowance.Ports.Any(static p => p is < 1 or > 65535)) Throw("sandbox.network.allow contains an invalid host or port."); + } + foreach (ExecutableApproval approval in value.Sandbox.Process.Approvals) + { + if (!Path.IsPathFullyQualified(approval.Path) || !Sha256Regex().IsMatch(approval.Sha256)) Throw("process approval requires an absolute path and 64 hexadecimal SHA-256 characters."); + } + if (value.FileEdit.MemoryBackupLimitMb <= 0 || value.Limits.WholeReadBytes <= 0 || value.Limits.SearchMaxResults <= 0 || value.Limits.TailMaxLines <= 0 || value.Limits.ContextSummaryRatio is <= 0 or >= 1) Throw("file/context limits are invalid."); + if (value.Logging.MaxFileBytes <= 0 || value.Logging.RetainedFiles < 1) Throw("logging limits are invalid."); + + RequireFile(baseDirectory, value.Agent.UserSystemPrompt, "agent.user_system_prompt"); + RequireDirectory(baseDirectory, value.Localization.Directory, "localization.directory"); + RequireDirectory(baseDirectory, value.Skills.Directory, "skills.directory"); + } + + private static void RequireFile(string baseDirectory, string path, string key) + { + if (!File.Exists(Resolve(baseDirectory, path))) Throw($"{key} does not exist: {path}"); + } + + private static void RequireDirectory(string baseDirectory, string path, string key) + { + if (!Directory.Exists(Resolve(baseDirectory, path))) Throw($"{key} does not exist: {path}"); + } + + public static string Resolve(string baseDirectory, string path) => Path.GetFullPath(path, baseDirectory); + private static void Throw(string message) => throw new ConfigurationException(message); + [GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)] + private static partial Regex Sha256Regex(); +} |
