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(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(); }